diff --git a/server/index.ts b/server/index.ts index 6d86894..90494c7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import transmissionDriver from './torrent-clients/transmission.js' import synologyDsDriver from './torrent-clients/synology-ds.js' import utorrentDriver from './torrent-clients/utorrent.js' import rtorrentDriver from './torrent-clients/rtorrent.js' +import realDebridDriver from './torrent-clients/real-debrid.js' import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js' import { logger } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' @@ -33,6 +34,7 @@ registerDriver(transmissionDriver) registerDriver(synologyDsDriver) registerDriver(utorrentDriver) registerDriver(rtorrentDriver) +registerDriver(realDebridDriver) const app = express() const PORT = Number(process.env.PORT) || 9898 diff --git a/server/organize-worker.ts b/server/organize-worker.ts index ff9be17..9769cd4 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -253,6 +253,26 @@ function seasonFolder(n: number): string { return n === 0 ? 'Specials' : `Saison ${n}` } +/** + * Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre). + * 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur. + */ +async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await fsp.unlink(filePath) + return + } catch (err: any) { + if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) { + warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`) + await new Promise(r => setTimeout(r, 500)) + } else { + throw err + } + } + } +} + function tryHardlink(src: string, dest: string): boolean { try { fs.linkSync(src, dest); return true } catch (err) { @@ -270,7 +290,13 @@ function sanitizeDirName(name: string): string { } // ─── Organise un torrent ────────────────────────────────────── -async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) { +async function organizeTorrent( + hash : string, + name : string, + savePath : string, + seriesData : any[], + completedFileNames: Set | null = null, +) { const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings() const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] } @@ -325,6 +351,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 } + // Vérification de complétion pour épisode unique + if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) { + debug(`Fichier incomplet (skip) : ${filename}`) + return { ...result, total: 1, skipped: 1 } + } + const candidates = filePath ? [ path.join(savePath, filePath), path.join(completePath, filePath), @@ -356,7 +388,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser episode: ep.episode_number, episode_id: ep.id, src_filename: filename, dest_filename: destName, dest_path: dest, }) - await fsp.unlink(src) + await unlinkWithRetry(src) log(`${destName} → Saison ${season.season_number}`) return { ...result, total: 1, done: 1 } } @@ -385,6 +417,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) { if (isOrganized(hash, episode_id)) { result.skipped++; continue } + // Vérification de complétion : si on a les infos de progression par fichier, + // on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée). + if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) { + debug(`Fichier incomplet (skip) : ${filename}`) + result.skipped++ + continue + } + const candidates = [ path.join(savePath, fullPath), path.join(completePath, fullPath), @@ -433,7 +473,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser episode: episode_number, episode_id, src_filename: filename, dest_filename: nfo_filename, dest_path: dest, }) - await fsp.unlink(src) + await unlinkWithRetry(src) } result.done++ debug(`${nfo_filename} → Saison ${season_number}`) @@ -479,7 +519,29 @@ parentPort?.on('message', async (msg: any) => { } try { - const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData) + // Construire l'ensemble des fichiers 100% complets à partir des infos de progression. + // On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission). + // Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage. + let completedFileNames: Set | null = null + if (Array.isArray(t.files) && t.files.length > 0) { + // Vérifier qu'au moins un fichier a un nom non vide + const hasNames = t.files.some((f: any) => f.name && String(f.name).trim()) + if (hasNames) { + completedFileNames = new Set( + t.files + .filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim()) + .map((f: any) => { + // f.name est un chemin relatif ex: "Pack S1/Episode1.mkv" + // on ne garde que le basename pour comparer au filename du fileMap + const parts = String(f.name).replace(/\\/g, '/').split('/') + return (parts[parts.length - 1] ?? '').toLowerCase() + }) + .filter(Boolean) + ) + } + } + + const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames) parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result }) } catch (err) { error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`) diff --git a/server/organize.ts b/server/organize.ts index 8a51fc4..db2d730 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -397,8 +397,9 @@ export async function migrateOrganizedEpisodeIds( if (newId && newId !== oldId) { // Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) { - logger.warn('organize', `Migration ID : conflit ep ${oldId} → ${newId} — entrée existante conservée`) - newEpisodes[oldIdStr] = entry + // Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement + changed = true + logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`) } else { newEpisodes[String(newId)] = { ...entry, episode_id: newId } updated++ diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index aa5e981..a84aae5 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -36,7 +36,8 @@ export interface SavedClient { export interface TorrentFileProgress { index : number - progress: number // 0–100 + name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client + progress: number // 0–1 } export interface TorrentInfo { @@ -70,6 +71,7 @@ export interface TorrentClientDriver { add : (config: Record, url: string, options?: DownloadOptions) => Promise list : (config: Record, category?: string) => Promise remove : (config: Record, hash: string, deleteFiles?: boolean) => Promise + getFiles ?: (config: Record, hash: string) => Promise } // ─── Registry ───────────────────────────────────────────────────────────────── @@ -341,4 +343,27 @@ export async function dispatchList( } return results +} + +// ─── Dispatch file progress ──────────────────────────────────────────────────── +// Retourne la liste des fichiers d'un torrent avec leur progression (0–1). +// clientUuid optionnel : si fourni, interroge uniquement ce client. + +export async function dispatchGetFiles( + hash : string, + clientUuid?: string, +): Promise { + const clients = clientUuid + ? loadClients().filter(c => c.uuid === clientUuid) + : loadClients() + + for (const client of clients) { + const driver = getDriver(client.type) + if (!driver?.getFiles) continue + try { + const files = await driver.getFiles(client.config, hash) + if (files.length > 0) return files + } catch {} + } + return [] } \ No newline at end of file diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 3b73d60..54fc857 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -57,9 +57,9 @@ async function qbApplyFilePriority( fileIndex: number, resume : boolean, ): Promise { - // 60 tentatives × 500 ms = 30 s max + // Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s for (let attempt = 0; attempt < 60; attempt++) { - await new Promise(r => setTimeout(r, 500)) + await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500)) try { const sid = await qbLogin(config) const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { @@ -223,12 +223,30 @@ const QB: TorrentClientDriver = { save_path : t.save_path, category : t.category ?? '', files : fileInfo.length > 0 - ? fileInfo.map((f: any, i: number) => ({ index: i, progress: Math.round((f.progress ?? 0) * 100) })) + ? fileInfo.map((f: any, i: number) => ({ + index : i, + name : String(f.name ?? ''), + progress: f.progress ?? 0, // 0–1 + })) : undefined, } satisfies TorrentInfo }) }, + async getFiles(config, hash) { + const sid = await qbLogin(config) + const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { + headers: { Cookie: `SID=${sid}` }, + }) + if (!res.ok) return [] + const data: any[] = await res.json() + return data.map((f: any, i: number) => ({ + index : i, + name : String(f.name ?? ''), + progress: f.progress ?? 0, + })) + }, + async add(config, url, options?: DownloadOptions) { const sid = await qbLogin(config) @@ -259,15 +277,11 @@ const QB: TorrentClientDriver = { } catch {} } - // Ajout du torrent en pause immédiate (toutes versions qBit) - // paused=true → qBit < 5.0 - // stopped=true → qBit ≥ 5.0 + // Ajout normal du torrent (pas en pause) pour que qBit récupère les métadonnées immédiatement const form = new FormData() form.append('urls', url) if (config.category) form.append('category', String(config.category)) if (config.savePath) form.append('savepath', String(config.savePath)) - form.append('paused', 'true') - form.append('stopped', 'true') const res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` }, @@ -276,7 +290,7 @@ const QB: TorrentClientDriver = { if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`) if (hash) { - logger.info('qbittorrent', `Torrent ajouté (en pause, sélection fichier ${options.file_index} en attente)`) + logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`) qbApplyFilePriority(config, hash, options.file_index, true).catch(err => logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) ) diff --git a/server/torrent-clients/real-debrid.ts b/server/torrent-clients/real-debrid.ts new file mode 100644 index 0000000..a21cf2c --- /dev/null +++ b/server/torrent-clients/real-debrid.ts @@ -0,0 +1,254 @@ +/** + * Real-Debrid Driver + * + * Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs, + * puis les fichiers sont accessibles via des liens directs (HTTPS). + * + * Workflow FanKarr : + * 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni + * 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding') + * 3. remove() → DELETE /torrents/delete/{id} + * + * Le save_path de chaque torrent est construit à partir du localPath configuré + * (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre). + */ + +import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js' +import { logger } from '../logger.js' + +const RD_BASE = 'https://api.real-debrid.com/rest/1.0' + +function rdHeaders(config: Record) { + return { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + } +} + +async function rdFetch( + config : Record, + method : 'GET' | 'POST' | 'DELETE', + path : string, + body ?: URLSearchParams, +): Promise { + const res = await fetch(`${RD_BASE}${path}`, { + method, + headers: rdHeaders(config), + body : body, + }) + if (res.status === 204) return null + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`) + } + return res.json() +} + +function mapStatus(status: string): TorrentInfo['state'] { + switch (status) { + case 'downloaded': return 'seeding' + case 'downloading': + case 'queued': + case 'compressing': + case 'uploading': + case 'magnet_conversion': return 'downloading' + case 'waiting_files_selection': return 'paused' + case 'magnet_error': + case 'error': + case 'virus': + case 'dead': return 'error' + default: return 'unknown' + } +} + +const rdDriver: TorrentClientDriver = { + definition: { + id : 'real-debrid', + label : 'Real-Debrid', + fields: [ + { key: 'apiKey', label: 'Clé API', type: 'password', required: true }, + { key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false }, + ], + }, + + async test(config) { + try { + const user = await rdFetch(config, 'GET', '/user') + logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`) + return { ok: true, message: `Connecté en tant que ${user.username}` } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('real-debrid', `Test de connexion échoué : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const user = await rdFetch(config, 'GET', '/user') + return { online: true, version: `RD — ${user.username}` } + } catch { + return { online: false } + } + }, + + async add(config, url, options?: DownloadOptions) { + const isMagnet = url.startsWith('magnet:') + const hash = (options?.infohash?.toLowerCase() ?? null) + || url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase() + || null + + // Vérifier si le torrent est déjà présent sur RD + if (hash) { + try { + const list: any[] = await rdFetch(config, 'GET', '/torrents') + const existing = list.find((t: any) => t.hash?.toLowerCase() === hash) + if (existing) { + logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`) + if (options?.file_index != null && existing.status === 'waiting_files_selection') { + const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`) + .then((info: any) => info.files ?? []) + const fileId = files[options.file_index]?.id + if (fileId) { + await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`, + new URLSearchParams({ files: String(fileId) })) + } + } + return + } + } catch {} + } + + let torrentId: string + + if (isMagnet) { + const data = await rdFetch(config, 'POST', '/torrents/addMagnet', + new URLSearchParams({ magnet: url })) + torrentId = data.id + logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`) + } else { + // URL de fichier .torrent : télécharger puis uploader + const torrentRes = await fetch(url) + if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`) + const torrentBuf = await torrentRes.arrayBuffer() + const formData = new FormData() + formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent') + const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, { + method : 'PUT', + headers: { Authorization: `Bearer ${config.apiKey}` }, + body : formData, + }) + if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`) + const data = await uploadRes.json() + torrentId = data.id + logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`) + } + + // Sélection des fichiers + if (options?.file_index != null) { + // Attendre que la liste des fichiers soit disponible (état waiting_files_selection) + let files: any[] = [] + for (let attempt = 0; attempt < 20; attempt++) { + await new Promise(r => setTimeout(r, 500)) + try { + const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`) + if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) { + files = info.files + break + } + if (info.status === 'downloading' || info.status === 'downloaded') break + } catch {} + } + if (files.length > 0 && options.file_index < files.length) { + const fileId = files[options.file_index]?.id + if (fileId) { + await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`, + new URLSearchParams({ files: String(fileId) })) + logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`) + } + } else if (files.length > 0) { + // Sélectionner tous les fichiers si index hors limites + await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`, + new URLSearchParams({ files: 'all' })) + } + } else { + // Pas de sélection spécifique : tous les fichiers + // Attendre waiting_files_selection pour envoyer "all" + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise(r => setTimeout(r, 500)) + try { + const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`) + if (info.status === 'waiting_files_selection') { + await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`, + new URLSearchParams({ files: 'all' })) + break + } + if (info.status !== 'magnet_conversion') break + } catch {} + } + } + }, + + async list(config, _category?) { + const torrents: any[] = await rdFetch(config, 'GET', '/torrents') + const localBase = String(config.localPath ?? '').replace(/\/+$/, '') + + return torrents.map((t: any): TorrentInfo => { + const progress = typeof t.progress === 'number' ? t.progress / 100 : 0 + // Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent + const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : '' + + // Les liens sont les fichiers téléchargés (disponibles si status=downloaded) + const files: TorrentFileProgress[] | undefined = + Array.isArray(t.files) && t.files.length > 0 + ? t.files.map((f: any, i: number) => ({ + index : i, + name : String(f.path ?? f.name ?? ''), + progress: f.selected ? progress : 0, + })) + : undefined + + return { + hash : String(t.hash ?? t.id ?? '').toLowerCase(), + name : String(t.filename ?? t.id ?? ''), + state : mapStatus(t.status ?? ''), + progress : Math.round(progress * 100), + size : typeof t.bytes === 'number' ? t.bytes : 0, + downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0, + uploaded : 0, + ratio : 0, + speed : typeof t.speed === 'number' ? t.speed : 0, + upspeed : 0, + eta : -1, + save_path, + category : '', + files, + } + }) + }, + + async remove(config, hash, _deleteFiles?) { + // Retrouver l'ID RD depuis le hash + const list: any[] = await rdFetch(config, 'GET', '/torrents') + const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase()) + if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`) + await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`) + logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`) + }, + + async getFiles(config, hash) { + const list: any[] = await rdFetch(config, 'GET', '/torrents') + const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase()) + if (!target) return [] + const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`) + const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0 + if (!Array.isArray(info.files)) return [] + return info.files.map((f: any, i: number) => ({ + index : i, + name : String(f.path ?? f.name ?? ''), + progress: f.selected ? overallProgress : 0, + })) + }, +} + +export default rdDriver diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index 1e7cdf4..21cec36 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -244,7 +244,7 @@ const RT: TorrentClientDriver = { await Promise.all(rows.map(async (r: any) => { const hash = String(r[0]).toUpperCase() try { - const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=']) + const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=']) if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files) } catch {} })) @@ -277,7 +277,8 @@ const RT: TorrentClientDriver = { files : fileInfo.length > 0 ? fileInfo.map((f: any, i: number) => ({ index : i, - progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0, + name : String(f[2] ?? ''), + progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0, })) : undefined, } satisfies TorrentInfo diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index 1928f9e..4588abe 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -161,7 +161,8 @@ const TR: TorrentClientDriver = { files : Array.isArray(t.files) && t.files.length > 0 ? t.files.map((f: any, i: number) => ({ index : i, - progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0, + name : String(f.name ?? ''), + progress: f.length > 0 ? f.bytesCompleted / f.length : 0, })) : undefined, } satisfies TorrentInfo)) diff --git a/src/components/serie/SerieHero.vue b/src/components/serie/SerieHero.vue index 61c00e6..2209daf 100644 --- a/src/components/serie/SerieHero.vue +++ b/src/components/serie/SerieHero.vue @@ -9,9 +9,9 @@
-
+
- @@ -55,7 +67,7 @@