Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04252db0c4 | ||
|
|
aeb2e938ef | ||
|
|
3799d33c62 | ||
|
|
98b317d493 | ||
|
|
4292f96645 | ||
|
|
182c7cc807 | ||
|
|
53be057425 | ||
|
|
ac70dba927 | ||
|
|
f79cc244ef | ||
|
|
14137e1b1b | ||
|
|
43ee4597a9 | ||
|
|
f311c8e2f3 | ||
|
|
48933b4078 | ||
|
|
45288a130f | ||
|
|
73256eb15d | ||
|
|
6855c04fab | ||
|
|
f6f735ad88 | ||
|
|
a7250a5f80 | ||
|
|
d326e08834 | ||
|
|
0e2d513cf7 | ||
|
|
5cf530ba78 | ||
|
|
3bc2e08ca5 |
No files matched your search
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
|
||||||
|
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
|
||||||
|
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
|
||||||
|
|
||||||
|
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
|
||||||
|
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
|
||||||
|
|
||||||
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 231 KiB |
@@ -2,8 +2,9 @@ import { DATA_DIR } from '../config.js'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
const GITHUB_BASE =
|
||||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
process.env.GITHUB_BASE?.trim() ||
|
||||||
|
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||||
|
|
||||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ function isOrganized(hash: string, episodeId: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche par infohash (méthode principale).
|
||||||
|
*/
|
||||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||||
const h = hash.toLowerCase()
|
const h = hash.toLowerCase()
|
||||||
for (const sd of seriesData) {
|
for (const sd of seriesData) {
|
||||||
@@ -101,6 +105,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
|
||||||
|
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
|
||||||
|
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
|
||||||
|
*/
|
||||||
|
function findTorrentByName(
|
||||||
|
name : string,
|
||||||
|
seriesData: any[],
|
||||||
|
): { torrent: any; serieData: any; infohash: string } | null {
|
||||||
|
const n = name.toLowerCase().trim()
|
||||||
|
if (!n) return null
|
||||||
|
|
||||||
|
for (const sd of seriesData) {
|
||||||
|
// Pack intégral (torrents au niveau série)
|
||||||
|
for (const t of sd.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const season of sd.seasons ?? []) {
|
||||||
|
// Pack saison
|
||||||
|
for (const t of season.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torrent épisode unique
|
||||||
|
for (const ep of season.episodes ?? []) {
|
||||||
|
for (const t of ep.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function buildFileMap(
|
function buildFileMap(
|
||||||
serieData : any,
|
serieData : any,
|
||||||
hash : string,
|
hash : string,
|
||||||
@@ -504,7 +555,22 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
for (const t of torrents) {
|
for (const t of torrents) {
|
||||||
if (t.state !== 'seeding') continue
|
if (t.state !== 'seeding') continue
|
||||||
|
|
||||||
const found = findTorrentByHash(t.hash, seriesData)
|
// Résolution principale : par infohash
|
||||||
|
let found = findTorrentByHash(t.hash, seriesData)
|
||||||
|
let effectiveHash = t.hash
|
||||||
|
|
||||||
|
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
|
||||||
|
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
|
||||||
|
// qui correspond au champ torrent_name dans les données série.
|
||||||
|
if (!found && t.name) {
|
||||||
|
const byName = findTorrentByName(t.name, seriesData)
|
||||||
|
if (byName) {
|
||||||
|
found = byName
|
||||||
|
effectiveHash = byName.infohash
|
||||||
|
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||||
continue
|
continue
|
||||||
@@ -512,8 +578,8 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
|
|
||||||
const { torrent, serieData } = found
|
const { torrent, serieData } = found
|
||||||
const seasonFilter = torrent._season?.season_number
|
const seasonFilter = torrent._season?.season_number
|
||||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
|
||||||
const orgHash = organized[t.hash] ?? {}
|
const orgHash = organized[effectiveHash] ?? {}
|
||||||
const allDone = fileMap.size > 0
|
const allDone = fileMap.size > 0
|
||||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||||
: Object.keys(orgHash).length > 0
|
: Object.keys(orgHash).length > 0
|
||||||
@@ -546,8 +612,8 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
|
||||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result })
|
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -520,13 +520,17 @@ export async function organizeTorrent(
|
|||||||
hash : string,
|
hash : string,
|
||||||
name : string,
|
name : string,
|
||||||
savePath : string,
|
savePath : string,
|
||||||
seriesData: any[]
|
seriesData: any[],
|
||||||
|
files? : { name?: string; progress: number; priority?: number }[],
|
||||||
): Promise<OrganizeResult> {
|
): Promise<OrganizeResult> {
|
||||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(resolveWorkerPath())
|
const worker = new Worker(resolveWorkerPath())
|
||||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
// files : progression par fichier récupérée depuis le client torrent.
|
||||||
|
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
|
||||||
|
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
|
||||||
|
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
|
||||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||||
|
|
||||||
worker.on('message', (msg: any) => {
|
worker.on('message', (msg: any) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from '../logger.js'
|
|||||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||||
|
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||||
import { DATA_DIR } from '../config.js'
|
import { DATA_DIR } from '../config.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -36,7 +37,13 @@ router.post('/organize', requireAuth, async (req, res) => {
|
|||||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||||
try {
|
try {
|
||||||
const seriesData = await loadEnrichedSeriesData()
|
const seriesData = await loadEnrichedSeriesData()
|
||||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
|
||||||
|
// Récupère la progression par fichier depuis le client torrent.
|
||||||
|
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
|
||||||
|
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
|
||||||
|
const files = await dispatchGetFiles(hash).catch(() => [])
|
||||||
|
|
||||||
|
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||||
if (result.done > 0 || result.errors.length > 0) {
|
if (result.done > 0 || result.errors.length > 0) {
|
||||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ export interface FieldDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TorrentClientDefinition {
|
export interface TorrentClientDefinition {
|
||||||
id : string
|
id : string
|
||||||
label : string
|
label : string
|
||||||
fields : FieldDef[]
|
fields : FieldDef[]
|
||||||
|
/** Si true, dispatchList filtre les résultats pour n'inclure que les
|
||||||
|
* torrents ajoutés via FanKarr (clients sans catégories natives). */
|
||||||
|
filterByManagedHashes?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedClient {
|
export interface SavedClient {
|
||||||
@@ -94,7 +97,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
|||||||
|
|
||||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||||
|
|
||||||
|
// ─── Managed tasks ─────────────────────────────────────────────────────────────
|
||||||
|
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
|
||||||
|
// Utilisé par dispatchList pour filtrer les résultats des clients sans
|
||||||
|
// catégories natives (ex: Synology Download Station).
|
||||||
|
// Structure : { clientUuid: [infohash, ...] }
|
||||||
|
|
||||||
|
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
|
||||||
|
|
||||||
|
function loadManagedMap(): Record<string, string[]> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(MANAGED_PATH)) return {}
|
||||||
|
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
|
||||||
|
} catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManagedMap(data: Record<string, string[]>) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
|
||||||
|
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
|
||||||
|
function getManagedSet(clientUuid: string): Set<string> | null {
|
||||||
|
const list = loadManagedMap()[clientUuid]
|
||||||
|
if (!list || list.length === 0) return null
|
||||||
|
return new Set(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre un infohash comme géré pour un client (idempotent). */
|
||||||
|
export function trackManagedHash(clientUuid: string, infohash: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) data[clientUuid] = []
|
||||||
|
const h = infohash.toLowerCase()
|
||||||
|
if (!data[clientUuid].includes(h)) {
|
||||||
|
data[clientUuid].push(h)
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire un infohash de la liste gérée pour un client. */
|
||||||
|
export function untrackManagedHash(clientUuid: string, infohash: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) return
|
||||||
|
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
|
||||||
|
if (data[clientUuid].length === 0) delete data[clientUuid]
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
|
||||||
|
function clearManagedHashes(clientUuid: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) return
|
||||||
|
delete data[clientUuid]
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
|
||||||
function loadClients(): SavedClient[] {
|
function loadClients(): SavedClient[] {
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +189,7 @@ export function removeClient(uuid: string): boolean {
|
|||||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||||
if (filtered.length === clients.length) return false
|
if (filtered.length === clients.length) return false
|
||||||
saveClients(filtered)
|
saveClients(filtered)
|
||||||
|
clearManagedHashes(uuid)
|
||||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -243,6 +304,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await driver.add(client.config, url, options)
|
await driver.add(client.config, url, options)
|
||||||
|
if (options?.infohash) {
|
||||||
|
trackManagedHash(client.uuid, options.infohash)
|
||||||
|
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
|
||||||
|
}
|
||||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -266,6 +331,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
|
|||||||
try {
|
try {
|
||||||
await driver.remove(client.config, hash, deleteFiles)
|
await driver.remove(client.config, hash, deleteFiles)
|
||||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||||
|
untrackManagedHash(client.uuid, hash)
|
||||||
found = true
|
found = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
@@ -328,6 +394,14 @@ export async function dispatchList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtre par hashes gérés pour les clients sans catégories natives.
|
||||||
|
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
|
||||||
|
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
|
||||||
|
if (driver.definition.filterByManagedHashes) {
|
||||||
|
const managed = getManagedSet(client.uuid)
|
||||||
|
if (managed && !managed.has(hash)) continue
|
||||||
|
}
|
||||||
|
|
||||||
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -120,9 +120,10 @@ async function dsRequest(
|
|||||||
|
|
||||||
const DS: TorrentClientDriver = {
|
const DS: TorrentClientDriver = {
|
||||||
definition: {
|
definition: {
|
||||||
id : 'synology-ds',
|
id : 'synology-ds',
|
||||||
label : 'Synology Download Station',
|
label : 'Synology Download Station',
|
||||||
fields: [
|
filterByManagedHashes: true,
|
||||||
|
fields : [
|
||||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||||
@@ -172,7 +173,17 @@ const DS: TorrentClientDriver = {
|
|||||||
|
|
||||||
const tasks: any[] = data?.tasks ?? []
|
const tasks: any[] = data?.tasks ?? []
|
||||||
|
|
||||||
return tasks.map(t => {
|
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
|
||||||
|
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
|
||||||
|
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
|
||||||
|
const filtered = normalizedSavePath
|
||||||
|
? tasks.filter(t => {
|
||||||
|
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
|
||||||
|
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
|
||||||
|
})
|
||||||
|
: tasks
|
||||||
|
|
||||||
|
return filtered.map(t => {
|
||||||
const transfer = t.additional?.transfer ?? {}
|
const transfer = t.additional?.transfer ?? {}
|
||||||
const detail = t.additional?.detail ?? {}
|
const detail = t.additional?.detail ?? {}
|
||||||
const fileList: any[] = t.additional?.file ?? []
|
const fileList: any[] = t.additional?.file ?? []
|
||||||
@@ -235,37 +246,51 @@ const DS: TorrentClientDriver = {
|
|||||||
|
|
||||||
const sid = await dsLogin(config)
|
const sid = await dsLogin(config)
|
||||||
|
|
||||||
const makeBody = (withDestination: boolean) => {
|
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
|
||||||
const body = new URLSearchParams({
|
const beforeIds = new Set<string>()
|
||||||
|
try {
|
||||||
|
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||||
|
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const doAdd = async (withDestination: boolean) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
api : 'SYNO.DownloadStation.Task',
|
api : 'SYNO.DownloadStation.Task',
|
||||||
version: '1',
|
version: '1',
|
||||||
method : 'create',
|
method : 'create',
|
||||||
_sid : sid,
|
_sid : sid,
|
||||||
uri : url,
|
uri : url,
|
||||||
})
|
})
|
||||||
if (withDestination && config.savePath) body.append('destination', String(config.savePath))
|
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
|
||||||
return body
|
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||||
}
|
|
||||||
|
|
||||||
const doRequest = async (body: URLSearchParams) => {
|
|
||||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
|
|
||||||
method : 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body : body.toString(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = await doRequest(makeBody(true))
|
let data = await doAdd(true)
|
||||||
|
|
||||||
// Code 403 = destination inexistante sur le NAS → réessai sans destination
|
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
|
||||||
if (!data.success && data.error?.code === 403 && config.savePath) {
|
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||||
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
||||||
data = await doRequest(makeBody(false))
|
data = await doAdd(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
||||||
|
|
||||||
|
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
|
||||||
|
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
|
||||||
|
if (options?.infohash) {
|
||||||
|
try {
|
||||||
|
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||||
|
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
|
||||||
|
if (newTask) {
|
||||||
|
const synoId = String(newTask.id).toLowerCase()
|
||||||
|
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
|
||||||
|
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -606,33 +606,47 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
|
|||||||
*/
|
*/
|
||||||
function _parseEpChap(plot: string | null | undefined): {
|
function _parseEpChap(plot: string | null | undefined): {
|
||||||
episodes: string | null
|
episodes: string | null
|
||||||
chapters: string | null
|
season: number | null
|
||||||
|
chapters: string |null
|
||||||
cleanPlot: string
|
cleanPlot: string
|
||||||
} {
|
} {
|
||||||
const text = plot ?? ''
|
const text = plot ?? ''
|
||||||
|
|
||||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
const epMatch = text.match(
|
||||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
|
||||||
|
)
|
||||||
|
|
||||||
|
const seasonMatch = text.match(/saison\s+(\d+)/i)
|
||||||
|
|
||||||
|
const chapMatch = text.match(
|
||||||
|
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
|
||||||
|
)
|
||||||
|
|
||||||
const episodes = epMatch
|
const episodes = epMatch
|
||||||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||||||
: null
|
: null
|
||||||
const chapters = chapMatch
|
|
||||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
const season = epMatch?.[3]
|
||||||
: null
|
? Number(epMatch[3])
|
||||||
|
: seasonMatch
|
||||||
|
? Number(seasonMatch[1])
|
||||||
|
: null
|
||||||
|
|
||||||
|
const chapters = chapMatch
|
||||||
|
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||||
|
: null
|
||||||
|
|
||||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
|
||||||
let cleanPlot = text
|
let cleanPlot = text
|
||||||
if (episodes || chapters) {
|
if (episodes || chapters || season) {
|
||||||
cleanPlot = text
|
cleanPlot = text
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { episodes, chapters, cleanPlot }
|
return { episodes, season, chapters, cleanPlot }
|
||||||
}
|
}
|
||||||
function parseEpChap(plot: string | null | undefined) {
|
function parseEpChap(plot: string | null | undefined) {
|
||||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="modal.config[field.key]"
|
v-model="modal.config[field.key]"
|
||||||
:type="field.type === 'password' ? 'password' : 'text'"
|
:type="field.type === 'password' ? 'password' : 'text'"
|
||||||
placeholder=""
|
:placeholder="field.placeholder ?? ''"
|
||||||
class="settings-input"
|
class="settings-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,11 +254,7 @@ const clientMatrix = [
|
|||||||
{
|
{
|
||||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
|
|
||||||
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const modal = ref({
|
const modal = ref({
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.19.1
|
3.20.2
|
||||||
Reference in new issue
Block a user