From 39463bfbcb74c7241420504ba51bd91108df20e4 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Wed, 6 May 2026 16:41:53 +0200 Subject: [PATCH] fix: utilisation de formated_name depuis les paths feat: ajout de la mise a jour des id's --- server/index.ts | 15 +++-- server/lib/serie-helpers.ts | 24 ++++++++ server/organize-worker.ts | 32 +++++----- server/organize.ts | 115 ++++++++++++++++++++++++++++++++---- server/routes/import.ts | 23 +++++--- server/routes/organize.ts | 16 ++++- 6 files changed, 185 insertions(+), 40 deletions(-) diff --git a/server/index.ts b/server/index.ts index aca2bf8..6d86894 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,7 +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 { autoOrganizeAll, scanMediaPath, syncFilenameChanges } from './organize.js' +import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js' import { logger } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js' @@ -97,7 +97,10 @@ server.listen(PORT, async () => { } catch {} loadEnrichedSeriesData() - .then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)) + .then(async seriesData => { + await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) + return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) + }) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) const autoOrganize = async () => { @@ -144,8 +147,12 @@ server.listen(PORT, async () => { // Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches. setInterval(async () => { try { - const seriesData = await loadEnrichedSeriesData() - const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) + const seriesData = await loadEnrichedSeriesData() + const organizedPath = path.join(DATA_DIR, 'organized.json') + const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) + if (updated > 0) + logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`) + const { renamed } = await syncFilenameChanges(seriesData, organizedPath) if (renamed > 0) logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`) } catch (err) { diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index 9b103e0..4ce7c51 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -43,6 +43,30 @@ export function extractTorrentsFromSerieData(sd: any): any[] { return result } +/** + * Résout les noms d'un épisode en préférant les valeurs du path entry + * correspondant au hash donné (spécifique au torrent/encoding), + * avec fallback sur les champs de niveau épisode. + * + * Utilisation : + * const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash) + */ +export function resolveEpNaming(ep: any, hash?: string | null): { + formatted_name : string | null + nfo_filename : string | null + original_filename: string | null +} { + const h = hash?.toLowerCase() ?? null + const pathEntry = h + ? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h) + : null + return { + formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null, + nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null, + original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null, + } +} + /** * Dédoublonne une liste d'épisodes par episode_number. * Quand plusieurs variantes existent (ex. x264 + x265), on garde la première diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 5d52a55..ff9be17 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -129,12 +129,12 @@ function buildFileMap( if (!matchedPath) continue const filename = matchedPath.split('/').pop() ?? matchedPath - // Préférer le formatted_name du path (spécifique à ce torrent) sur celui de l'épisode - const rawFmtName = matchedPathObj?.formatted_name?.trim() - ? matchedPathObj.formatted_name - : ep.formatted_name - const fmtName = rawFmtName?.trim() - ? rawFmtName.replace(/[<>:"/\\|?*]/g, '') + '.mkv' + // Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding) + const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name + const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename + const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename + const fmtName = rawFmt?.trim() + ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null if (!nfoSupport && !fmtName) { @@ -143,8 +143,8 @@ function buildFileMap( const srcExt = path.extname(filename) const resolvedName = nfoSupport - ? (ep.nfo_filename ?? ep.original_filename ?? filename) - : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename) + ? (rawNfo ?? rawOriginal ?? filename) + : (fmtName ?? rawNfo ?? rawOriginal ?? filename) const destName = swapExtension(resolvedName, srcExt) map.set(filename, { @@ -306,18 +306,18 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser } } const filename = filePath?.split('/').pop() ?? name - // Préférer le formatted_name du path (spécifique à ce torrent) sur celui de l'épisode - const rawFmtName = matchedPathObj?.formatted_name?.trim() - ? matchedPathObj.formatted_name - : ep.formatted_name - const fmtName = rawFmtName?.trim() - ? rawFmtName.replace(/[<>:"/\\|?*]/g, '') + '.mkv' + // Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding) + const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name + const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename + const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename + const fmtName = rawFmt?.trim() + ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null const srcExt = path.extname(filename) const resolvedName = nfoSupport - ? (ep.nfo_filename ?? ep.original_filename ?? filename) - : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename) + ? (rawNfo ?? rawOriginal ?? filename) + : (fmtName ?? rawNfo ?? rawOriginal ?? filename) const destName = swapExtension(resolvedName, srcExt) const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) diff --git a/server/organize.ts b/server/organize.ts index 2798b4b..8a51fc4 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -100,10 +100,12 @@ export async function scanMediaPath( const srcFilename = p.path.replace(/\\/g, '/').split('/').pop() if (!srcFilename) continue idx(srcFilename, hash) - if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename) - // formatted_name spécifique à ce torrent (priorité sur celui de l'épisode) - if (p.formatted_name?.trim()) { - const fmtBase = p.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + // nfo_filename et formatted_name du path en priorité sur ceux de l'épisode + const pathNfo = p.nfo_filename ?? ep.nfo_filename + if (pathNfo) idx(pathNfo, hash, pathNfo) + const pathFmt = p.formatted_name ?? ep.formatted_name + if (pathFmt?.trim()) { + const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim() idx(fmtBase, hash, fmtBase) } } @@ -332,6 +334,100 @@ export async function autoOrganizeAll( worker.postMessage({ type: 'run', torrents, seriesData }) } +// ── Migration des IDs épisodes ──────────────────────────────── +// +// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages +// du scraper. On utilise (infohash, season_number, episode_number) comme clé +// stable pour retrouver le nouvel ID et mettre à jour organized.json. + +export async function migrateOrganizedEpisodeIds( + organizedPath: string, + seriesData : any[] +): Promise<{ updated: number; orphaned: number }> { + + // 1. Construire les tables de lookup depuis le catalogue actuel + // hashEpMap : infohash → Map<"S:E", new_episode_id> + const hashEpMap = new Map>() + const validIds = new Set() + + for (const sd of seriesData) { + for (const season of sd.seasons ?? []) { + for (const ep of season.episodes ?? []) { + validIds.add(ep.id) + for (const p of ep.paths ?? []) { + if (typeof p !== 'object' || !p.infohash) continue + const h = p.infohash.toLowerCase() + const key = `${season.season_number}:${ep.episode_number}` + if (!hashEpMap.has(h)) hashEpMap.set(h, new Map()) + hashEpMap.get(h)!.set(key, ep.id) + } + } + } + } + + // 2. Charger organized.json + let organized: Organized = {} + try { + if (fs.existsSync(organizedPath)) + organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) + } catch { return { updated: 0, orphaned: 0 } } + + let updated = 0 + let orphaned = 0 + let changed = false + + for (const [hash, episodes] of Object.entries(organized)) { + if (hash === 'manual') continue + const epMap = hashEpMap.get(hash.toLowerCase()) + + const newEpisodes: Record = {} + for (const [oldIdStr, entry] of Object.entries(episodes as Record)) { + const oldId = Number(oldIdStr) + + if (validIds.has(oldId)) { + // ID encore valide, aucun changement + newEpisodes[oldIdStr] = entry + continue + } + + // ID orphelin — chercher le nouvel ID via (hash, saison, épisode) + const key = `${entry.season}:${entry.episode}` + const newId = epMap?.get(key) + + 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 + } else { + newEpisodes[String(newId)] = { ...entry, episode_id: newId } + updated++ + changed = true + logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`) + } + } else { + // Introuvable — on conserve pour ne pas perdre l'entrée + orphaned++ + newEpisodes[oldIdStr] = entry + logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`) + } + } + + organized[hash] = newEpisodes + // Nettoyer les hash vides + if (Object.keys(organized[hash]).length === 0) delete organized[hash] + } + + if (changed) { + fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`) + } else { + logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`) + } + + return { updated, orphaned } +} + // ── Sync des noms de fichiers après mise à jour du scraper ──── export async function syncFilenameChanges( @@ -367,16 +463,15 @@ export async function syncFilenameChanges( ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const srcExt = path.extname(currentName) - // Préférer le formatted_name du path correspondant au hash organisé + // Priorité aux valeurs du path entry correspondant au hash organisé const matchedPathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === orgHash ) - const fmtName = matchedPathEntry?.formatted_name?.trim() - ? matchedPathEntry.formatted_name - : ep.formatted_name + const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name + const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename const expectedName: string = nfoSupport - ? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName) - : (fmtName?.trim() ? fmtName.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) + ? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName) + : (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) if (expectedName === currentName) continue diff --git a/server/routes/import.ts b/server/routes/import.ts index 632348f..2070423 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js' import { readSettings } from '../settings.js' import { getGitlabTitle } from '../gitlab-map.js' import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js' +import { resolveEpNaming } from '../lib/serie-helpers.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { dispatchRemove } from '../torrent-clients/index.js' @@ -51,10 +52,11 @@ router.post('/manual-import', requireAuth, async (req, res) => { const { ep, season } = found const srcFilename = path.basename(file_path) const srcExt = path.extname(srcFilename) - const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null + const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash) + const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null const resolvedName = nfoSupport - ? (ep.nfo_filename ?? ep.original_filename ?? srcFilename) - : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename) + ? (rawNfo ?? rawOrig ?? srcFilename) + : (fmtName ?? rawNfo ?? rawOrig ?? srcFilename) const resolvedExt = path.extname(resolvedName) const destFilename = resolvedExt && resolvedExt !== srcExt ? resolvedName.slice(0, -resolvedExt.length) + srcExt @@ -186,11 +188,12 @@ router.post('/rename-episode', requireAuth, async (req, res) => { 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) + const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, entryHash) let newName: string if (nfoSupport) { - newName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName + newName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName } else { - newName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName + newName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName } if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return } const oldPath = orgEntry.dest_path @@ -338,11 +341,12 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { if (!orgEntry) continue const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const srcExt = path.extname(currentName) + const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash) let expectedName: string if (nfoSupport) { - expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName + expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName } else { - expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName + expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName } const needsRename = expectedName !== currentName const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false @@ -380,11 +384,12 @@ router.post('/rename-all', requireAuth, async (req, res) => { if (!orgEntry || !orgHash) continue const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const srcExt = path.extname(currentName) + const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash) let expectedName: string if (nfoSupport) { - expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName + expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName } else { - expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName + expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName } if (expectedName === currentName) continue const oldPath = orgEntry.dest_path diff --git a/server/routes/organize.ts b/server/routes/organize.ts index 98ec1db..6c04943 100644 --- a/server/routes/organize.ts +++ b/server/routes/organize.ts @@ -1,12 +1,26 @@ import { Router } from 'express' import { requireAuth } from '../auth.js' import { logger } from '../logger.js' -import { organizeTorrent } from '../organize.js' +import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js' import { loadEnrichedSeriesData } from '../lib/github-cache.js' import { recentOrganized, pushNotif } from '../lib/notifs.js' +import { DATA_DIR } from '../config.js' +import path from 'path' const router = Router() +router.post('/organize/migrate-ids', requireAuth, async (_req, res) => { + try { + const seriesData = await loadEnrichedSeriesData() + const organizedPath = path.join(DATA_DIR, 'organized.json') + const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData) + res.json({ ok: true, ...result }) + } catch (err) { + logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`) + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) + } +}) + router.get('/organize/recent', requireAuth, (_req, res) => { res.json(recentOrganized) })