From 8ec323dc95171ce0d19110019295d4daf5786502 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Thu, 9 Apr 2026 18:30:34 +0200 Subject: [PATCH] fix: remove log for mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: unlink before move fix: l'organiseur prend les fichiers renommer maintenant feat: ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard fix: ajout d'une verif lors de l'écriture de la config sur les items dans body fix: ne sauvegarde plus le mots de passe "fake" quand on edit un client feat: ajout du driver rtorrent fix: empeche un login failed de se repeter et de se faire bloquer par les clients --- server/index.ts | 79 ++++++-- server/organize-worker.ts | 102 ++++++---- server/organize.ts | 92 ++++++--- server/torrent-clients/index.ts | 70 ++++++- server/torrent-clients/rtorrent.ts | 235 ++++++++++++++++++++++ src/views/settings/DownloadClientView.vue | 23 ++- 6 files changed, 519 insertions(+), 82 deletions(-) create mode 100644 server/torrent-clients/rtorrent.ts diff --git a/server/index.ts b/server/index.ts index 5b4111d..0da832c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,7 +13,8 @@ import { import qbittorrentDriver from './torrent-clients/qbittorrent.js' 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 utorrent from "./torrent-clients/utorrent"; import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' @@ -23,7 +24,8 @@ import { systemInfo } from './system.js' registerDriver(qbittorrentDriver) registerDriver(transmissionDriver) registerDriver(synologyDsDriver) -registerDriver(utorrentDriver) +registerDriver(rtorrentDriver) +registerDriver(utorrent) const app = express() const PORT = Number(process.env.PORT) || 9898 @@ -54,7 +56,15 @@ app.get('/api/settings', requireAuth, (_req, res) => { }) app.post('/api/settings', requireAuth, (req, res) => { const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body - const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode }) + const patch: Record = {} + if (mediaPath !== undefined) patch.mediaPath = mediaPath + if (completePath !== undefined) patch.completePath = completePath + if (organizeMode !== undefined) patch.organizeMode = organizeMode + if (category !== undefined) patch.category = category + if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport + if (autoImport !== undefined) patch.autoImport = autoImport + if (devMode !== undefined) patch.devMode = devMode + const updated = writeSettings(patch) logger.info('api', 'Paramètres mis à jour') res.json(updated) }) @@ -81,6 +91,18 @@ app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => { const { name, type, config } = req.body if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return } if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } + // Restaurer les mots de passe masqués avec les valeurs actuelles + const oldClient = getClient(String(req.params.uuid)) + if (oldClient) { + const driver = getDriver(type) + if (driver) { + for (const field of driver.definition.fields) { + if (field.type === 'password' && config[field.key] === '••••••••') { + config[field.key] = oldClient.config[field.key] + } + } + } + } const updated = updateClient(String(req.params.uuid), name, type, config) if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return } res.json(sanitizeClient(updated)) @@ -225,7 +247,7 @@ async function loadEnrichedSeriesData(): Promise { return allSd.filter(Boolean) } -function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' { +function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' { if (!serieData) return 'none' const allTorrents = extractTorrentsFromSerieData(serieData) if (allTorrents.length === 0) return 'none' @@ -243,7 +265,9 @@ function computeSerieDownloadState(serieData: any | null, organized: Record { const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) return { ...normalizeSeason(season), episodes } })) - let organized: Record> = {} + let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const availableEpisodeIds = new Set() const episodeTorrentMap : Record = {} @@ -299,7 +323,10 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { const resolved = buildResolvedEpisodes(serieData, t.infohash) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } + for (const ep of resolved) { + const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined + if (isOrg) organizedEpisodeIds.add(ep.episode_id) + } } for (const season of serieData.seasons ?? []) { for (const t of (season.torrents ?? [])) { @@ -307,16 +334,25 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } + for (const ep of resolved) { + const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined + if (isOrg) organizedEpisodeIds.add(ep.episode_id) + } } for (const ep of season.episodes ?? []) { for (const t of (ep.torrents ?? [])) { episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true } availableEpisodeIds.add(ep.id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) - const filename = match ? match.path.split('/').pop() : null - if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) + const isOrg = orgFiles[String(ep.id)] !== undefined + if (!isOrg) { + // fallback ancien format + const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) + const filename = match ? match.path.split('/').pop() : null + if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) + } else { + organizedEpisodeIds.add(ep.id) + } } } } @@ -365,7 +401,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { try { const { category } = readSettings() const torrents = await dispatchList(category ?? 'fankai') - let organized: Record> = {} + let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const enriched = await Promise.all(torrents.map(async (t: any) => { const orgFiles = organized[t.hash] ?? {} @@ -504,7 +540,7 @@ app.get('/api/debug/stats', requireAuth, (req, res) => { const orgPath = path.join(DATA_DIR, 'organized.json') if (fs.existsSync(orgPath)) { const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) - organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0) + organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0) } } catch {} @@ -723,6 +759,23 @@ server.listen(PORT, async () => { const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const { mediaPath } = readSettings() + + // Reset organized.json au nouveau format (clé episode_id au lieu de filename) + // Si le fichier existe et contient l'ancien format (valeurs string), on le vide + // et on relance le scan pour reconstruire proprement + try { + if (fs.existsSync(ORGANIZED_PATH)) { + const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) + const isOldFormat = Object.values(raw).some((entries: any) => + Object.values(entries).some(v => typeof v === 'string') + ) + if (isOldFormat) { + logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation') + fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8') + } + } + } catch {} + loadEnrichedSeriesData() .then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 9bd54f7..24bf475 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -18,6 +18,20 @@ function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'war function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) } function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) } +// ─── Types ──────────────────────────────────────────────────── +export interface OrgEntry { + at : string + season : number + episode : number + episode_id : number + src_filename : string + dest_filename: string + dest_path : string +} + +// organized.json : Record> +type Organized = Record> + // ─── Settings ───────────────────────────────────────────────── function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { try { @@ -28,22 +42,22 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode } // ─── Organized log ──────────────────────────────────────────── -function loadOrganized(): Record> { +function loadOrganized(): Organized { try { if (!fs.existsSync(ORGANIZED_PATH)) return {} return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch { return {} } } -function markOrganized(hash: string, filename: string) { +function markOrganized(hash: string, episodeId: number, entry: OrgEntry) { const data = loadOrganized() if (!data[hash]) data[hash] = {} - data[hash][filename] = new Date().toISOString() + data[hash][String(episodeId)] = entry fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8') } -function isOrganized(hash: string, filename: string): boolean { - return !!loadOrganized()[hash]?.[filename] +function isOrganized(hash: string, episodeId: number): boolean { + return !!loadOrganized()[hash]?.[String(episodeId)] } // ─── Lookup torrent dans les seriesData ─────────────────────── @@ -90,9 +104,8 @@ function buildFileMap( hash : string, nfoSupport : boolean, seasonFilter?: number, -): Map { - // FIX : type de retour utilise nfo_filename au lieu de original_filename - const map = new Map() +): Map { + const map = new Map() const h = hash.toLowerCase() for (const season of serieData.seasons ?? []) { @@ -121,8 +134,6 @@ function buildFileMap( warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`) } - // FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut - // On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv) const srcExt = path.extname(filename) const resolvedName = nfoSupport ? (ep.nfo_filename ?? ep.original_filename ?? filename) @@ -130,9 +141,11 @@ function buildFileMap( const destName = swapExtension(resolvedName, srcExt) map.set(filename, { - season_number: season.season_number, - nfo_filename : destName, - fullPath : matchedPath, + season_number : season.season_number, + episode_number: ep.episode_number, + episode_id : ep.id, + nfo_filename : destName, + fullPath : matchedPath, }) } } @@ -240,8 +253,6 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi // ─── Filesystem ops ─────────────────────────────────────────── -// FIX : remplace l'extension d'un nom de fichier par celle du fichier source -// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv" function swapExtension(filename: string, ext: string): string { const currentExt = path.extname(filename) if (!currentExt || currentExt === ext) return filename @@ -304,18 +315,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null - // FIX : priorité à nfo_filename pour épisode unique aussi - // On swap l'extension (.nfo → extension du fichier source) - const srcExt = path.extname(filename) + const srcExt = path.extname(filename) const resolvedName = nfoSupport ? (ep.nfo_filename ?? ep.original_filename ?? filename) : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename) const destName = swapExtension(resolvedName, srcExt) - const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) - const dest = path.join(destDir, destName) + const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) + const dest = path.join(destDir, destName) - if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 } + if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 } const candidates = filePath ? [ path.join(savePath, filePath), @@ -328,11 +337,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser ] let src: string | null = null - - for (const c of candidates) { - debug(`Candidate : ${c} → ${fs.existsSync(c)}`) - if (fs.existsSync(c)) { src = c; break } - } + for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } if (!src) { error(`Fichier source introuvable : ${filename}`) @@ -345,10 +350,22 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest) } else { await fsp.copyFile(src, dest) + markOrganized(hash, ep.id, { + at: new Date().toISOString(), season: season.season_number ?? 1, + episode: ep.episode_number, episode_id: ep.id, + src_filename: filename, dest_filename: destName, dest_path: dest, + }) await fsp.unlink(src) + log(`${destName} → Saison ${season.season_number}`) + return { ...result, total: 1, done: 1 } } } - markOrganized(hash, filename) + + markOrganized(hash, ep.id, { + at: new Date().toISOString(), season: season.season_number ?? 1, + episode: ep.episode_number, episode_id: ep.id, + src_filename: filename, dest_filename: destName, dest_path: dest, + }) log(`${destName} → Saison ${season.season_number}`) return { ...result, total: 1, done: 1 } } @@ -364,9 +381,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser result.total = fileMap.size - // FIX : destructure nfo_filename au lieu de original_filename - for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) { - if (isOrganized(hash, filename)) { result.skipped++; continue } + for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) { + if (isOrganized(hash, episode_id)) { result.skipped++; continue } const candidates = [ path.join(savePath, fullPath), @@ -386,15 +402,32 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number)) const dest = path.join(destDir, nfo_filename) - if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue } + if (fs.existsSync(dest)) { + markOrganized(hash, episode_id, { + at: new Date().toISOString(), season: season_number, + episode: episode_number, episode_id, + src_filename: filename, dest_filename: nfo_filename, dest_path: dest, + }) + result.skipped++ + continue + } try { fs.mkdirSync(destDir, { recursive: true }) if (organizeMode === 'hardlink') { if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest) + markOrganized(hash, episode_id, { + at: new Date().toISOString(), season: season_number, + episode: episode_number, episode_id, + src_filename: filename, dest_filename: nfo_filename, dest_path: dest, + }) } else { await fsp.copyFile(src, dest) - markOrganized(hash, filename) + markOrganized(hash, episode_id, { + at: new Date().toISOString(), season: season_number, + episode: episode_number, episode_id, + src_filename: filename, dest_filename: nfo_filename, dest_path: dest, + }) await fsp.unlink(src) } result.done++ @@ -430,9 +463,10 @@ parentPort?.on('message', async (msg: any) => { const { torrent, serieData } = found const seasonFilter = torrent._season?.season_number const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter) + const orgHash = organized[t.hash] ?? {} const allDone = fileMap.size > 0 - ? [...fileMap.keys()].every(f => organized[t.hash]?.[f]) - : !!organized[t.hash]?.[t.name] + ? [...fileMap.values()].every(f => orgHash[String(f.episode_id)]) + : Object.keys(orgHash).length > 0 if (allDone) { debug(`"${t.name}" déjà entièrement importé — skip`) diff --git a/server/organize.ts b/server/organize.ts index d88faff..e4aac8c 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -30,6 +30,18 @@ export interface OrganizeResult { errors : { file: string; error: string }[] } +interface OrgEntry { + at : string + season : number + episode : number + episode_id : number + src_filename : string + dest_filename: string + dest_path : string +} + +type Organized = Record> + // ── Scan initial ────────────────────────────────────────────── function swapExt(filename: string, ext: string): string { @@ -52,10 +64,15 @@ export async function scanMediaPath( logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) - // FIX : on indexe le nom brut ET les noms de destination possibles - // (nfo_filename, formatted_name) pour matcher les fichiers déjà renommés - // Map : nom_sur_disque → { hash, srcFilename } - const filenameIndex = new Map() + // Map : nom_sur_disque → { hash, srcFilename, episodeId, season, episode } + const filenameIndex = new Map() for (const sd of seriesData) { for (const season of sd.seasons ?? []) { @@ -69,39 +86,46 @@ export async function scanMediaPath( if (!srcFilename) continue const srcExt = path.extname(srcFilename) + const entry = { + hash, + srcFilename, + episodeId : ep.id, + season : season.season_number, + episode : ep.episode_number, + destFilename: srcFilename, + } - // 1. Nom brut du fichier source - filenameIndex.set(srcFilename, { hash, srcFilename }) + // 1. Nom brut source + filenameIndex.set(srcFilename, entry) // 2. nfo_filename avec extension swappée if (ep.nfo_filename) { const nfoRenamed = swapExt(ep.nfo_filename, srcExt) - filenameIndex.set(nfoRenamed, { hash, srcFilename }) + filenameIndex.set(nfoRenamed, { ...entry, destFilename: nfoRenamed }) } // 3. original_filename if (ep.original_filename) { - filenameIndex.set(ep.original_filename, { hash, srcFilename }) + filenameIndex.set(ep.original_filename, { ...entry, destFilename: ep.original_filename }) } // 4. formatted_name + .mkv if (ep.formatted_name?.trim()) { const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv' - filenameIndex.set(fmtName, { hash, srcFilename }) + filenameIndex.set(fmtName, { ...entry, destFilename: fmtName }) } } } } } - let organized: Record> = {} + let organized: Organized = {} try { if (fs.existsSync(organizedPath)) organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) } catch {} - // FIX : on track les paires hash:srcFilename (clé dans organized.json) - // et non hash:nom_sur_disque + // Track les paires hash:episodeId présentes const presentFiles = new Set() function walk(dir: string) { @@ -117,32 +141,37 @@ export async function scanMediaPath( const match = filenameIndex.get(entry.name) if (!match) continue - const { hash, srcFilename } = match - // La clé dans organized.json est toujours le srcFilename (nom brut) - presentFiles.add(`${hash}:${srcFilename}`) - if (organized[hash]?.[srcFilename]) continue + const { hash, srcFilename, episodeId, season, episode, destFilename } = match + presentFiles.add(`${hash}:${episodeId}`) + + if (organized[hash]?.[String(episodeId)]) continue + if (!organized[hash]) organized[hash] = {} - organized[hash][srcFilename] = new Date().toISOString() + organized[hash][String(episodeId)] = { + at : new Date().toISOString(), + season, + episode, + episode_id : episodeId, + src_filename : srcFilename, + dest_filename: destFilename, + dest_path : full, + } result.added++ - logger.debug('organize', `Scan match : "${entry.name}" → hash ${hash.slice(0, 8)}… (src: ${srcFilename})`) + logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`) } } } walk(mediaPath) - // FIX : supprimer uniquement les entrées dont le fichier source - // n'est plus présent ET qui sont connues dans l'index - // (évite de supprimer des entrées pour des fichiers hors catalogue) - const allSrcFilenames = new Set([...filenameIndex.values()].map(v => v.srcFilename)) + // Supprimer uniquement les entrées connues dans l'index et absentes du disque + const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`)) let removed = 0 - for (const [hash, files] of Object.entries(organized)) { - for (const srcFilename of Object.keys(files)) { - // Ne supprimer que si le fichier est dans notre catalogue - // ET qu'il n'est plus présent dans la médiathèque - if (allSrcFilenames.has(srcFilename) && !presentFiles.has(`${hash}:${srcFilename}`)) { - delete organized[hash][srcFilename] + for (const [hash, episodes] of Object.entries(organized)) { + for (const episodeId of Object.keys(episodes)) { + if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) { + delete organized[hash][episodeId] removed++ } } @@ -197,11 +226,10 @@ export async function autoOrganizeAll( for (const t of torrents) _prevStates.set(t.hash, t.state) - let organized: Record> = {} + let organized: Organized = {} try { - const orgPath = path.join(DATA_DIR, 'organized.json') - if (fs.existsSync(orgPath)) - organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) + if (fs.existsSync(ORGANIZED_PATH)) + organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {} const hasUnorganized = torrents.some(t => diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index c7cf247..51821b0 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -139,6 +139,56 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe return { ...client, config } } +// ─── Backoff par client ─────────────────────────────────────────────────────── +// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP + +interface BackoffEntry { + failCount : number + nextRetry : number // timestamp ms +} + +const _backoff = new Map() + +// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné) +const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000] + +function isInCooldown(uuid: string): boolean { + const entry = _backoff.get(uuid) + if (!entry) return false + return Date.now() < entry.nextRetry +} + +function cooldownRemaining(uuid: string): string { + const entry = _backoff.get(uuid) + if (!entry) return '' + const remaining = Math.max(0, entry.nextRetry - Date.now()) + const m = Math.ceil(remaining / 60_000) + return m > 0 ? `${m}min` : '' +} + +function registerFailure(uuid: string, clientName: string) { + const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 } + entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0) + const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)] + entry.nextRetry = Date.now() + delay + _backoff.set(uuid, entry) + const min = Math.round(delay / 60_000) + logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`) +} + +function registerSuccess(uuid: string) { + _backoff.delete(uuid) +} + +// Exposé pour l'API de status +export function getClientCooldowns(): Record { + const result: Record = {} + for (const [uuid, entry] of _backoff.entries()) { + if (Date.now() < entry.nextRetry) result[uuid] = entry + } + return result +} + // ─── Remote path remapping ──────────────────────────────────────────────────── // FIX : remplace le préfixe remotePath par localPath dans save_path @@ -194,25 +244,41 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & { for (const client of clients) { const driver = getDriver(client.type) if (!driver) continue + + // Skip si le client est en cooldown suite à des échecs d'auth + if (isInCooldown(client.uuid)) { + const remaining = cooldownRemaining(client.uuid) + logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`) + continue + } + try { const clientCategory = (client.config.category as string) || category const torrents = await driver.list(client.config, clientCategory) + registerSuccess(client.uuid) + const remotePath = String(client.config.remotePath ?? '') const localPath = String(client.config.localPath ?? '') const hasRemap = remotePath && localPath for (const t of torrents) { - // FIX : remap save_path si remotePath et localPath sont configurés const save_path = hasRemap ? remapSavePath(t.save_path, remotePath, localPath) : t.save_path - results.push({ ...t, save_path, client_uuid: client.uuid, client_name: client.name }) } } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`) + // Déclencher le backoff si c'est une erreur d'auth + const isAuthError = msg.toLowerCase().includes('login') || + msg.toLowerCase().includes('auth') || + msg.toLowerCase().includes('ban') || + msg.toLowerCase().includes('forbidden') || + msg.toLowerCase().includes('401') || + msg.toLowerCase().includes('403') + if (isAuthError) registerFailure(client.uuid, client.name) } } diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts new file mode 100644 index 0000000..68b3aea --- /dev/null +++ b/server/torrent-clients/rtorrent.ts @@ -0,0 +1,235 @@ +/** + * rTorrent Driver + * =============== + * Communique via XML-RPC (HTTP POST). + * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). + */ + +import type { TorrentClientDriver, TorrentInfo } from './index.js' +import { logger } from '../logger.js' + +// ─── XML-RPC helpers ────────────────────────────────────────── + +function xmlValue(val: any): string { + if (typeof val === 'string') return `${val}` + if (typeof val === 'number') return `${val}` + if (typeof val === 'boolean') return `${val ? 1 : 0}` + if (Array.isArray(val)) return `${val.map(xmlValue).join('')}` + return `${String(val)}` +} + +function xmlCall(method: string, params: any[] = []): string { + return ` + + ${method} + ${params.map(p => `${xmlValue(p)}`).join('')} +` +} + +function parseXmlValue(node: Element): any { + const child = node.firstElementChild + if (!child) return node.textContent ?? '' + switch (child.tagName) { + case 'string' : return child.textContent ?? '' + case 'int' : + case 'i4' : + case 'i8' : return parseInt(child.textContent ?? '0', 10) + case 'double' : return parseFloat(child.textContent ?? '0') + case 'boolean' : return child.textContent === '1' + case 'array' : { + const data = child.querySelector('data') + if (!data) return [] + return Array.from(data.children).map(parseXmlValue) + } + case 'struct' : { + const result: Record = {} + for (const member of Array.from(child.querySelectorAll(':scope > member'))) { + const name = member.querySelector(':scope > name')?.textContent ?? '' + const value = member.querySelector(':scope > value') + if (name && value) result[name] = parseXmlValue(value) + } + return result + } + default: return child.textContent ?? '' + } +} + +async function rpcCall( + config : Record, + method : string, + params : any[] = [], +): Promise { + const body = xmlCall(method, params) + const headers : Record = { 'Content-Type': 'text/xml' } + + if (config.username && config.password) { + headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`) + } + + const rpcPath = String(config.rpcPath || '/RPC2') + const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}` + + const res = await fetch(url, { method: 'POST', headers, body }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + + const text = await res.text() + + // Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste + const faultMatch = text.match(/faultString<\/name>\s*([^<]*)<\/string>/) + if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`) + + const valueMatch = text.match(/\s*\s*\s*([\s\S]*?)<\/value>\s*<\/param>/) + if (!valueMatch) return null + + // Parse simplifié pour les types courants + const inner = valueMatch[1].trim() + + // Array + if (inner.startsWith('')) { + const items = [...inner.matchAll(/([\s\S]*?)<\/value>/g)] + return items.map(m => parseInnerValue(m[1].trim())) + } + + return parseInnerValue(inner) +} + +function parseInnerValue(inner: string): any { + if (inner.startsWith('')) { + const items = [...inner.matchAll(/([\s\S]*?)<\/value>/g)] + return items.map(m => parseInnerValue(m[1].trim())) + } + const strMatch = inner.match(/^([\s\S]*?)<\/string>$/) + if (strMatch) return strMatch[1] + const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/) + if (intMatch) return parseInt(intMatch[1], 10) + // Valeur brute sans tag + if (!inner.includes('<')) return inner + return inner +} + +// multicall pour récupérer plusieurs champs en une requête +async function d_multicall( + config : Record, + view : string, + methods: string[], +): Promise { + const params = [view, ...methods.map(m => `${m}=`)] + const result = await rpcCall(config, 'd.multicall2', ['', ...params]) + return Array.isArray(result) ? result : [] +} + +// ─── State mapping ──────────────────────────────────────────── +// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1) +// + is_open (0/1) +function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] { + if (isChecking) return 'checking' + if (!isOpen) return 'paused' + if (!isActive) return 'paused' + if (isComplete) return 'seeding' + return 'downloading' +} + +const RT: TorrentClientDriver = { + definition: { + id : 'rtorrent', + label : 'rTorrent', + fields: [ + { key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true }, + { key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' }, + { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false }, + { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false }, + { key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, + { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, + { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, + ], + }, + + async test(config) { + try { + const version = await rpcCall(config, 'system.client_version') + logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`) + return { ok: true, message: 'Connexion réussie' } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const version = await rpcCall(config, 'system.client_version') + logger.debug('rtorrent', `Healthcheck OK — version ${version}`) + return { online: true, version: String(version) } + } catch (err) { + logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) + return { online: false } + } + }, + + async list(config, category) { + const methods = [ + 'd.hash', + 'd.name', + 'd.is_open', + 'd.is_active', + 'd.is_hash_checking', + 'd.complete', + 'd.size_bytes', + 'd.bytes_done', + 'd.down.rate', + 'd.left_bytes', + 'd.down.rate', + 'd.directory', + 'd.custom1', // label rTorrent + ] + + const rows = await d_multicall(config, 'main', methods) + + return rows + .map(r => { + const [hash, name, isOpen, isActive, isChecking, isComplete, + size, downloaded, speed, leftBytes, , directory, label] = r + + const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0 + const eta = speed > 0 && leftBytes > 0 ? Math.round(leftBytes / speed) : -1 + + return { + hash : String(hash).toLowerCase(), + name : String(name), + state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)), + progress, + size : Number(size), + downloaded: Number(downloaded), + speed : Number(speed), + eta, + save_path : String(directory), + category : String(label ?? ''), + } satisfies TorrentInfo + }) + .filter(t => { + if (!category) return true + return t.category === category + }) + }, + + async add(config, url) { + // load_start charge et démarre le torrent + const method = url.startsWith('magnet:') ? 'load.start' : 'load.start' + const args: any[] = ['', url] + + if (config.savePath) { + args.push(`d.directory.set="${config.savePath}"`) + } + + if (config.category) { + args.push(`d.custom1.set=${config.category}`) + } + + await rpcCall(config, method, args) + logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) + }, +} + +export default RT \ No newline at end of file diff --git a/src/views/settings/DownloadClientView.vue b/src/views/settings/DownloadClientView.vue index e1e3619..f67c537 100644 --- a/src/views/settings/DownloadClientView.vue +++ b/src/views/settings/DownloadClientView.vue @@ -109,7 +109,7 @@