From e1e68ef2bf742cab23e8080aac7817be835c2e2c Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Thu, 23 Apr 2026 22:04:19 +0200 Subject: [PATCH] fix: renommage d'episodes qui ne fonctionne pas en 1 par 1 feat: ajout de log pour le bouton tout renommer feat: ajout du support multifile sur utorrent feat: ajout d'un bouton pour supprimer les nfo / png --- server/lib/github-cache.ts | 2 +- server/organize.ts | 77 +++++++++++++++ server/routes/import.ts | 62 +++++++++++- server/routes/system.ts | 12 ++- server/torrent-clients/utorrent.ts | 100 +++++++++++++++----- src/views/settings/DownloadClientView.vue | 2 +- src/views/settings/ImportManagementView.vue | 30 +++++- 7 files changed, 251 insertions(+), 34 deletions(-) diff --git a/server/lib/github-cache.ts b/server/lib/github-cache.ts index 25e44f1..8082da7 100644 --- a/server/lib/github-cache.ts +++ b/server/lib/github-cache.ts @@ -5,7 +5,7 @@ import fs from 'fs' const GITHUB_BASE = process.env.GITHUB_BASE ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main' -const CACHE_TTL_MS = 6 * 60 * 60 * 1000 +const CACHE_TTL_MS = 1 * 60 * 60 * 1000 interface CacheEntry { data: T; expiresAt: number } const _cache = new Map>() diff --git a/server/organize.ts b/server/organize.ts index 648edd1..37a1db4 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -327,6 +327,83 @@ export async function autoOrganizeAll( worker.postMessage({ type: 'run', torrents, seriesData }) } +// ── Sync des noms de fichiers après mise à jour du scraper ──── + +export async function syncFilenameChanges( + seriesData : any[], + organizedPath: string, +): Promise<{ renamed: number; errors: number }> { + const { nfoSupport } = readSettings() + let organized: Organized = {} + try { + if (fs.existsSync(organizedPath)) + organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) + } catch { return { renamed: 0, errors: 0 } } + + let renamed = 0 + let errors = 0 + let changed = false + + for (const sd of seriesData) { + for (const season of sd.seasons ?? []) { + for (const ep of season.episodes ?? []) { + let orgEntry: any = null + let orgHash: string | null = null + for (const [hash, eps] of Object.entries(organized)) { + if ((eps as any)[String(ep.id)]) { + orgEntry = (eps as any)[String(ep.id)] + orgHash = hash + break + } + } + if (!orgEntry || !orgHash) continue + + const currentName = orgEntry.dest_path + ? path.basename(orgEntry.dest_path) + : orgEntry.dest_filename + const srcExt = path.extname(currentName) + const expectedName: string = nfoSupport + ? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName) + : (ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) + + if (expectedName === currentName) continue + + const oldPath = orgEntry.dest_path + if (!oldPath || !fs.existsSync(oldPath)) continue + + const newPath = path.join(path.dirname(oldPath), expectedName) + try { + if (fs.existsSync(newPath)) { + logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`) + continue + } + fs.renameSync(oldPath, newPath) + ;(organized[orgHash] as any)[String(ep.id)] = { + ...orgEntry, + dest_filename: expectedName, + dest_path : newPath, + at : new Date().toISOString(), + } + renamed++ + changed = true + logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`) + } catch (err) { + errors++ + logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`) + } + } + } + } + + if (changed) + fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + + if (renamed > 0 || errors > 0) + logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) + + return { renamed, errors } +} + // ── Import manuel ───────────────────────────────────────────── export async function organizeTorrent( diff --git a/server/routes/import.ts b/server/routes/import.ts index 038e9d1..dd098fc 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -175,7 +175,13 @@ router.post('/rename-episode', requireAuth, async (req, res) => { if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return } const { ep, season } = foundEp const hash = torrent_hash?.toLowerCase() ?? 'manual' - const orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)] + let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)] + let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual' + if (!orgEntry) { + for (const [h, eps] of Object.entries(organized)) { + if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break } + } + } if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return } const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const srcExt = path.extname(currentName) @@ -192,7 +198,6 @@ router.post('/rename-episode', requireAuth, async (req, res) => { if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque') if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`) fs.renameSync(oldPath, newPath) - const entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual' organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } saveOrganizedJson(organized) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`) @@ -335,14 +340,24 @@ router.post('/rename-all', requireAuth, async (req, res) => { const oldPath = orgEntry.dest_path const newPath = path.join(path.dirname(oldPath), expectedName) try { - if (!fs.existsSync(oldPath)) { errors.push({ episode_id: ep.id, error: 'Fichier introuvable' }); continue } - if (fs.existsSync(newPath)) { errors.push({ episode_id: ep.id, error: `Fichier existant : ${expectedName}` }); continue } + if (!fs.existsSync(oldPath)) { + const msg = `Fichier introuvable : "${oldPath}"` + logger.error('api', `Rename masse ep ${ep.id} — ${msg}`) + errors.push({ episode_id: ep.id, error: msg }); continue + } + if (fs.existsSync(newPath)) { + const msg = `Fichier existant : "${expectedName}"` + logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`) + errors.push({ episode_id: ep.id, error: msg }); continue + } fs.renameSync(oldPath, newPath) organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() } done.push(ep.id) logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`) } catch (err) { - errors.push({ episode_id: ep.id, error: err instanceof Error ? err.message : 'Erreur' }) + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`) + errors.push({ episode_id: ep.id, error: msg }) } } } @@ -351,4 +366,41 @@ router.post('/rename-all', requireAuth, async (req, res) => { res.json({ ok: true, done: done.length, errors }) }) +// ── Purge NFO / images des dossiers série ───────────────────── +router.post('/purge-nfo', requireAuth, (req, res) => { + const { mediaPath } = readSettings() + if (!mediaPath || !fs.existsSync(mediaPath)) { + res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return + } + + const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml']) + let deleted = 0 + const errors: string[] = [] + + function walk(dir: string) { + let entries: fs.Dirent[] + try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) { + try { + fs.unlinkSync(full) + deleted++ + logger.debug('api', `Purge NFO : supprimé "${full}"`) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`) + errors.push(full) + } + } + } + } + + walk(mediaPath) + logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`) + res.json({ ok: true, deleted, errors }) +}) + export default router diff --git a/server/routes/system.ts b/server/routes/system.ts index 551850c..834deec 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js' import { DATA_DIR, BASE_DIR } from '../config.js' import { readSettings } from '../settings.js' import { systemInfo } from '../system.js' -import { scanMediaPath } from '../organize.js' +import { scanMediaPath, syncFilenameChanges } from '../organize.js' import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js' import { cacheSize } from '../lib/github-cache.js' import { recentOrganized } from '../lib/notifs.js' @@ -109,6 +109,16 @@ router.post('/update', requireAuth, async (_req, res) => { if (!Array.isArray(availableIds)) throw new Error('available.json invalide') logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) res.json({ ok: true, count: availableIds.length }) + ;(async () => { + try { + const seriesData = await loadEnrichedSeriesData() + const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) + if (renamed > 0) + logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) + } catch (err) { + logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`) + } + })() } catch (err) { logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) diff --git a/server/torrent-clients/utorrent.ts b/server/torrent-clients/utorrent.ts index eba04a0..0fb628c 100644 --- a/server/torrent-clients/utorrent.ts +++ b/server/torrent-clients/utorrent.ts @@ -21,6 +21,56 @@ function mapState(status: number): TorrentInfo['state'] { // uTorrent nécessite un token CSRF + cookie à chaque session interface UTSession { token: string; cookie: string } +async function utTorrentExists(config: Record, hash: string): Promise { + try { + const session = await utGetSession(config) + const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session) + const files = data?.files?.[1] + return Array.isArray(files) && files.length > 0 + } catch { return false } +} + +/** + * Attend que les métadonnées soient disponibles (polling), puis applique les priorités : + * tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal). + */ +async function utApplyFilePriority( + config : Record, + hash : string, + fileIndex: number, +): Promise { + const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) + const HASH = hash.toUpperCase() + + // 100 tentatives × 500 ms = 50 s max + for (let attempt = 0; attempt < 100; attempt++) { + await new Promise(r => setTimeout(r, 500)) + try { + const session = await utGetSession(config) + const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session) + const files = data?.files?.[1] as any[][] | undefined + if (!Array.isArray(files) || files.length === 0) continue + + // Passer tous les fichiers à priorité 0 (skip) + const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' }) + for (let i = 0; i < files.length; i++) skipQs.append('f', String(i)) + await fetch(`${config.url}/gui/?${skipQs}`, { + headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, + }) + + // Activer le fichier cible (priorité 2 = normal) + const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) }) + await fetch(`${config.url}/gui/?${selectQs}`, { + headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, + }) + + logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`) + return + } catch {} + } + throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`) +} + async function utGetSession(config: Record): Promise { const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const headers : Record = { @@ -142,43 +192,49 @@ const UT: TorrentClientDriver = { }, async add(config, url, options?: DownloadOptions) { - if (options?.file_index != null) logger.warn('utorrent', 'Sélection de fichier non supportée — téléchargement complet') + const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) + ?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) + const hash = hashMatch?.[1]?.toLowerCase() ?? null + + if (options?.file_index != null && hash) { + const exists = await utTorrentExists(config, hash) + if (exists) { + logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`) + utApplyFilePriority(config, hash, options.file_index).catch(err => + logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) + ) + return + } + } + const session = await utGetSession(config) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) - const form = new FormData() - form.append('torrent_url', url) - - const qs = new URLSearchParams({ - token : session.token, - action: 'add-url', - s : url, - }) - - if (config.savePath) { - qs.set('path', String(config.savePath)) - } + const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url }) + if (config.savePath) qs.set('path', String(config.savePath)) const res = await fetch(`${config.url}/gui/?${qs}`, { method : 'GET', - headers: { - 'Authorization': `Basic ${auth}`, - 'Cookie' : session.cookie, - }, + headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, }) - if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`) + if (options?.file_index != null && hash) { + logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`) + utApplyFilePriority(config, hash, options.file_index).catch(err => + logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) + ) + } else { + logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) + } + // Appliquer le label/catégorie si défini if (config.category) { - // On ne peut pas set le label au moment de l'ajout, il faut attendre - // que le torrent soit chargé — on le fait via setprops après un délai setTimeout(async () => { try { const s2 = await utGetSession(config) const data = await utRequest(config, { list: '1' }, s2) const torrents: any[][] = data?.torrents ?? [] - // Trouver le torrent le plus récemment ajouté sans label const target = torrents.find(t => !t[11]) if (target) { await utRequest(config, { @@ -191,8 +247,6 @@ const UT: TorrentClientDriver = { } catch {} }, 3000) } - - logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, async remove(config, hash, deleteFiles = false) { diff --git a/src/views/settings/DownloadClientView.vue b/src/views/settings/DownloadClientView.vue index 4e49256..ab3922f 100644 --- a/src/views/settings/DownloadClientView.vue +++ b/src/views/settings/DownloadClientView.vue @@ -166,7 +166,7 @@ const clientMatrix = [ { id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true }, { id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true }, { id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true }, - { id: 'utorrent', label: 'uTorrent', fileSelect: '✗', categories: true }, + { id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true }, { id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false }, ] diff --git a/src/views/settings/ImportManagementView.vue b/src/views/settings/ImportManagementView.vue index 3e7712b..fe7e29b 100644 --- a/src/views/settings/ImportManagementView.vue +++ b/src/views/settings/ImportManagementView.vue @@ -22,6 +22,14 @@ Actualiser +