From 7f06dd654a72b4960195202ef5597411a54f8d75 Mon Sep 17 00:00:00 2001 From: ElPouki Date: Fri, 11 Sep 2026 15:10:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20actions=20de=20masse=20sur=20les=20s?= =?UTF-8?q?=C3=A9ries=20et=20fiabilisation=20des=20imports=20et=20correcti?= =?UTF-8?q?on=20protection=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 +- .gitattributes | 1 + .gitignore | 5 + index.html | 2 +- server/index.ts | 55 ++-- server/lib/github-cache.ts | 34 ++- server/lib/organized-store.ts | 43 +++ server/lib/rss-sync.ts | 12 + server/organize-worker.ts | 26 +- server/organize.ts | 274 +++++++++++++------- server/routes/import.ts | 136 ++++++---- server/routes/rss-sync.ts | 16 +- server/routes/series.ts | 14 +- server/routes/system.ts | 26 +- src/components/series/SeriesToolbar.vue | 17 +- src/stores/series.ts | 4 + src/views/SerieView.vue | 83 +++--- src/views/SeriesView.vue | 111 +++++++- src/views/settings/ImportManagementView.vue | 33 +++ 19 files changed, 637 insertions(+), 258 deletions(-) create mode 100644 .gitattributes create mode 100644 server/lib/organized-store.ts diff --git a/.dockerignore b/.dockerignore index 4aa2887..7c587ab 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,5 @@ node_modules config .env .env.* -!.env.example \ No newline at end of file +!.env.example +.test-sandbox diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 4c1c0c7..32d3508 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ binaries *.njsproj *.sln *.sw? + +# Instance de test locale +.test-sandbox +scripts/seed-test-sandbox.mjs +docker-compose.test.yml diff --git a/index.html b/index.html index e6164da..df41c5b 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - fankarr + Fankarr
diff --git a/server/index.ts b/server/index.ts index 8b7765f..1d47697 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,10 +14,11 @@ import utorrentDriver from './torrent-clients/utorrent.js' import rtorrentDriver from './torrent-clients/rtorrent.js' import realDebridDriver from './torrent-clients/real-debrid.js' import delugeDriver from './torrent-clients/deluge.js' -import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js' +import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } 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' +import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js' import { pushNotif } from './lib/notifs.js' import { readRequests, completeRequest } from './requests.js' import { checkNfoUpdates } from './lib/nfo.js' @@ -91,6 +92,9 @@ const ADMIN_PREFIXES = [ '/users', '/jellyfin', '/settings', '/torrent-clients', '/downloads', '/download', '/organize', '/import', '/system', '/nfo-updates', '/plex', '/rss-sync', + '/torrent', '/manual-import', '/organized', '/organized-summary', + '/rename-episode', '/rename-all', '/purge-nfo', + '/logs', '/browse', '/browse-files', '/update', '/scan', '/debug', ] app.use('/api', (req, res, next) => { if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) { @@ -135,40 +139,37 @@ server.listen(PORT, async () => { logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`) } - const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const { mediaPath } = readSettings() // Migration organized.json : si ancien format (valeurs string) → reset + rescan 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') - } + const raw = readOrganized() + 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') + writeOrganized({}) } - } catch {} + } catch (err) { + logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`) + } // Migration organized.json : backfill dest_dir manquant sur les anciennes entrées try { - if (fs.existsSync(ORGANIZED_PATH)) { - const raw: Record> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) - let patched = 0 - for (const episodes of Object.values(raw)) { - for (const entry of Object.values(episodes)) { - if (!entry.dest_dir && entry.dest_path) { - entry.dest_dir = path.dirname(entry.dest_path) - patched++ - } + const raw = readOrganized() + let patched = 0 + for (const episodes of Object.values(raw)) { + for (const entry of Object.values(episodes)) { + if (!entry.dest_dir && entry.dest_path) { + entry.dest_dir = path.dirname(entry.dest_path) + patched++ } } - if (patched > 0) { - fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2)) - logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`) - } + } + if (patched > 0) { + writeOrganized(raw) + logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`) } } catch (err) { logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`) @@ -177,6 +178,7 @@ server.listen(PORT, async () => { loadEnrichedSeriesData() .then(async seriesData => { await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) + dedupeOrganizedEpisodes(ORGANIZED_PATH) return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) }) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) @@ -245,6 +247,9 @@ server.listen(PORT, async () => { const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) if (updated > 0) logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`) + const { removed } = dedupeOrganizedEpisodes(organizedPath) + if (removed > 0) + logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`) const { renamed } = await syncFilenameChanges(seriesData, organizedPath) if (renamed > 0) logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`) diff --git a/server/lib/github-cache.ts b/server/lib/github-cache.ts index 36c3f1f..ddae1a3 100644 --- a/server/lib/github-cache.ts +++ b/server/lib/github-cache.ts @@ -26,34 +26,42 @@ export function cacheClear(): void { _cache.clear() } export function cacheSize(): number { return _cache.size } -export async function githubGet(urlPath: string): Promise { - const cached = cacheGet(urlPath) - if (cached) return cached - const res = await fetch(`${GITHUB_BASE}/${urlPath}`) +/** + * @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent + * (~5 min). + */ +export async function githubGet(urlPath: string, force = false): Promise { + if (!force) { + const cached = cacheGet(urlPath) + if (cached) return cached + } + const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}` + const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined + const res = await fetch(url, init) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`) const data = await res.json() cacheSet(urlPath, data) return data } -export async function readAvailable(): Promise { - try { return await githubGet('available.json') as number[] } +export async function readAvailable(force = false): Promise { + try { return await githubGet('available.json', force) as number[] } catch { return [] } } -export async function readInfohashMap(): Promise> { - try { return await githubGet('infohash_map.json') as Record } +export async function readInfohashMap(force = false): Promise> { + try { return await githubGet('infohash_map.json', force) as Record } catch { return {} } } -export async function readSerieData(serieId: number): Promise { - try { return await githubGet(`series/${serieId}.json`) } +export async function readSerieData(serieId: number, force = false): Promise { + try { return await githubGet(`series/${serieId}.json`, force) } catch { return null } } -export async function loadEnrichedSeriesData(): Promise { - const ids = await readAvailable() - const allSd = await Promise.all(ids.map(id => readSerieData(id))) +export async function loadEnrichedSeriesData(force = false): Promise { + const ids = await readAvailable(force) + const allSd = await Promise.all(ids.map(id => readSerieData(id, force))) return allSd.filter(Boolean) } diff --git a/server/lib/organized-store.ts b/server/lib/organized-store.ts new file mode 100644 index 0000000..31fc0bf --- /dev/null +++ b/server/lib/organized-store.ts @@ -0,0 +1,43 @@ +import fs from 'fs' +import path from 'path' +import { threadId } from 'worker_threads' +import { DATA_DIR } from '../config.js' + +export type Organized = Record> + +export const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') + +// Un fichier existant mais illisible lève une erreur : ne jamais repartir de {} avant d'écrire +export function readOrganized(file = ORGANIZED_PATH): Organized { + if (!fs.existsSync(file)) return {} + const raw = fs.readFileSync(file, 'utf-8') + let data: unknown + try { data = JSON.parse(raw) } + catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) } + if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu') + return data as Organized +} + +export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void { + for (const hash of Object.keys(data)) + if (!data[hash] || Object.keys(data[hash]).length === 0) delete data[hash] + const tmp = `${file}.${process.pid}-${threadId}.tmp` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8') + renameWithRetry(tmp, file) +} + +// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire +export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void { + const data = readOrganized(file) + if (fn(data) !== false) writeOrganized(data, file) +} + +function renameWithRetry(from: string, to: string, attempts = 5): void { + for (let i = 1; ; i++) { + try { fs.renameSync(from, to); return } + catch (err: any) { + if (i >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(err?.code)) throw err + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * i) + } + } +} diff --git a/server/lib/rss-sync.ts b/server/lib/rss-sync.ts index d2c35c0..c41dae8 100644 --- a/server/lib/rss-sync.ts +++ b/server/lib/rss-sync.ts @@ -53,6 +53,18 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S return map } +// Une série déjà surveillée garde sa date d'activation +export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } { + const map = loadSynced() + let changed = 0 + for (const { id, name } of series) { + if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ } + if (!enabled && map[id]) { delete map[id]; changed++ } + } + if (changed > 0) saveSynced(map) + return { changed, map } +} + export function isSynced(serieId: number): boolean { return !!loadSynced()[serieId] } diff --git a/server/organize-worker.ts b/server/organize-worker.ts index aa4baf0..01d58c2 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -10,8 +10,7 @@ import path from 'path' import { parentPort } from 'worker_threads' import { DATA_DIR } from './config.js' import { getGitlabTitle } from './gitlab-map.js' - -const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') +import { readOrganized } from './lib/organized-store.js' // ─── Utils log ──────────────────────────────────────────────── function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } @@ -44,22 +43,12 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode } // ─── Organized log ──────────────────────────────────────────── -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, episodeId: number, entry: OrgEntry) { - const data = loadOrganized() - if (!data[hash]) data[hash] = {} - data[hash][String(episodeId)] = entry - fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8') + parentPort?.postMessage({ type: 'mark', hash, episodeId, entry }) } function isOrganized(hash: string, episodeId: number): boolean { - return !!loadOrganized()[hash]?.[String(episodeId)] + return !!readOrganized()[hash]?.[String(episodeId)] } // ─── Lookup torrent dans les seriesData ─────────────────────── @@ -550,9 +539,16 @@ parentPort?.on('message', async (msg: any) => { const torrents: any[] = msg.torrents const seriesData: any[] = msg.seriesData ?? [] - const organized = loadOrganized() const { nfoSupport } = readSettings() + let organized: Organized + try { organized = readOrganized() as Organized } + catch (err) { + error(`Import annulé : ${err instanceof Error ? err.message : err}`) + parentPort?.postMessage({ type: 'done' }) + return + } + for (const t of torrents) { if (t.state !== 'seeding') continue diff --git a/server/organize.ts b/server/organize.ts index 7c84b7f..fec2935 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -8,10 +8,8 @@ import fs from 'fs' import path from 'path' import { Worker } from 'worker_threads' import { logger } from './logger.js' -import { DATA_DIR } from './config.js' import { readSettings } from './settings.js' - -const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') +import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js' export let workerRunning = false const _prevStates = new Map() @@ -30,18 +28,54 @@ 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 - dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage +type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] } + +// ── File d'attente des workers ──────────────────────────────── +let _queue: Promise = Promise.resolve() +let _queued = 0 +const _pendingManual = new Map>() + +function enqueue(job: () => Promise): Promise { + _queued++ + const run = _queue.then(job).finally(() => { _queued-- }) + _queue = run.catch(() => {}) + return run } -type Organized = Record> +function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise { + return new Promise((resolve, reject) => { + workerRunning = true + const worker = new Worker(resolveWorkerPath()) + let settled = false + const finish = (err?: Error) => { + if (settled) return + settled = true + workerRunning = false + worker.terminate() + err ? reject(err) : resolve() + } + + worker.on('message', (msg: any) => { + if (msg.type === 'log') { + logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) + } else if (msg.type === 'mark') { + try { + updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry }) + } catch (err) { + logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`) + } + } else if (msg.type === 'result') { + onResult(msg) + } else if (msg.type === 'done') { + finish() + } + }) + worker.on('error', err => finish(err)) + worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined)) + + worker.postMessage({ type: 'run', torrents, seriesData }) + }) +} // ── Scan initial ────────────────────────────────────────────── @@ -65,10 +99,8 @@ export async function scanMediaPath( logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) - // Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode } - // On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque const filenameIndex = new Map { const base = filename.replace(/\.[^.]+$/, '') if (!base) return + const existing = filenameIndex.get(base) + if (existing && existing.episodeId === ep.id) { + if (!existing.hashes.includes(hash)) existing.hashes.push(hash) + return + } filenameIndex.set(base, { - hash, + hashes : [hash], srcFilename : filename, episodeId : ep.id, season : season.season_number, @@ -123,11 +160,12 @@ export async function scanMediaPath( } } - let organized: Organized = {} - try { - if (fs.existsSync(organizedPath)) - organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) - } catch {} + let organized: Organized + try { organized = readOrganized(organizedPath) } + catch (err) { + logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`) + return result + } // Track les paires hash:episodeId présentes const presentFiles = new Set() @@ -167,7 +205,8 @@ export async function scanMediaPath( continue } - const { hash, srcFilename, episodeId, season, episode } = match + const { hashes, srcFilename, episodeId, season, episode } = match + const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0] presentFiles.add(`${hash}:${episodeId}`) if (organized[hash]?.[String(episodeId)]) continue @@ -190,11 +229,17 @@ export async function scanMediaPath( walk(mediaPath) + const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0 + if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`) + // 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}`)) + const allEpisodeIds = new Set( + [...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`)) + ) let removed = 0 for (const [hash, episodes] of Object.entries(organized)) { + if (mediaEmpty) break for (const episodeId of Object.keys(episodes)) { if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) { delete organized[hash][episodeId] @@ -207,7 +252,7 @@ export async function scanMediaPath( // Désimport automatique des fichiers manquants sur le disque const { autoUnimportMissing } = readSettings() as any let autoRemoved = 0 - if (autoUnimportMissing) { + if (autoUnimportMissing && !mediaEmpty) { for (const [hash, episodes] of Object.entries(organized)) { for (const [episodeId, entry] of Object.entries(episodes as Record)) { const destPath = entry?.dest_path @@ -222,7 +267,7 @@ export async function scanMediaPath( } if (result.added > 0 || removed > 0 || autoRemoved > 0) { - fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + writeOrganized(organized, organizedPath) } logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`) @@ -243,8 +288,8 @@ export async function autoOrganizeAll( return } - if (workerRunning) { - logger.debug('organize', 'Worker déjà en cours — skip') + if (_queued > 0) { + logger.debug('organize', 'Import déjà en cours — skip') return } @@ -268,11 +313,12 @@ export async function autoOrganizeAll( for (const t of torrents) _prevStates.set(t.hash, t.state) - let organized: Organized = {} - try { - if (fs.existsSync(ORGANIZED_PATH)) - organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) - } catch {} + let organized: Organized + try { organized = readOrganized() } + catch (err) { + logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`) + return + } const hasUnorganized = torrents.some(t => t.state === 'seeding' && !organized[t.hash?.toLowerCase()] @@ -290,13 +336,8 @@ export async function autoOrganizeAll( logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`) } - workerRunning = true - const worker = new Worker(resolveWorkerPath()) - - worker.on('message', (msg: any) => { - if (msg.type === 'log') { - logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) - } else if (msg.type === 'result') { + try { + await enqueue(() => runWorker(torrents, seriesData, msg => { if (msg.errors.length > 0) { logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`) for (const e of msg.errors) { @@ -316,24 +357,11 @@ export async function autoOrganizeAll( errors : msg.errors.length, errorFiles: msg.errors, }) - } else if (msg.type === 'done') { - logger.debug('organize', 'Worker terminé') - workerRunning = false - worker.terminate() - } - }) - - worker.on('error', (err) => { - logger.error('organize', `Worker erreur : ${err.message}`) - workerRunning = false - }) - worker.on('exit', (code) => { - if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`) - else logger.debug('organize', `Worker terminé (code ${code})`) - workerRunning = false - }) - - worker.postMessage({ type: 'run', torrents, seriesData }) + })) + logger.debug('organize', 'Worker terminé') + } catch (err) { + logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`) + } } // ── Migration des IDs épisodes ──────────────────────────────── @@ -368,11 +396,12 @@ export async function migrateOrganizedEpisodeIds( } // 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 organized: Organized + try { organized = readOrganized(organizedPath) } + catch (err) { + logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`) + return { updated: 0, orphaned: 0 } + } let updated = 0 let orphaned = 0 @@ -422,7 +451,7 @@ export async function migrateOrganizedEpisodeIds( } if (changed) { - fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + writeOrganized(organized, organizedPath) 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)` : ''}`) @@ -431,6 +460,58 @@ export async function migrateOrganizedEpisodeIds( return { updated, orphaned } } +// ── Déduplication : un seul enregistrement par épisode ──────── + +export function dedupeOrganizedEpisodes( + organizedPath: string, +): { removed: number } { + let organized: Organized + try { organized = readOrganized(organizedPath) } + catch (err) { + logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`) + return { removed: 0 } + } + + // episode_id → [{ hash, entry }] + const byEpisode = new Map() + for (const [hash, episodes] of Object.entries(organized)) { + for (const [episodeId, entry] of Object.entries(episodes as Record)) { + if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, []) + byEpisode.get(episodeId)!.push({ hash, entry }) + } + } + + let removed = 0 + for (const [episodeId, candidates] of byEpisode) { + if (candidates.length < 2) continue + + const score = (c: { entry: any }) => { + const exists = c.entry?.dest_path ? fs.existsSync(c.entry.dest_path) : false + const at = Date.parse(c.entry?.at ?? '') || 0 + return { exists, at } + } + const sorted = [...candidates].sort((a, b) => { + const sa = score(a), sb = score(b) + if (sa.exists !== sb.exists) return sa.exists ? -1 : 1 + return sb.at - sa.at + }) + + const keep = sorted[0] + for (const c of sorted.slice(1)) { + delete (organized[c.hash] as any)[episodeId] + removed++ + logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (conservé sous ${keep.hash.slice(0, 8)}…)`) + } + } + + if (removed > 0) { + writeOrganized(organized, organizedPath) + logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`) + } + + return { removed } +} + // ── Sync des noms de fichiers après mise à jour du scraper ──── export async function syncFilenameChanges( @@ -438,11 +519,12 @@ export async function syncFilenameChanges( 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 organized: Organized + try { organized = readOrganized(organizedPath) } + catch (err) { + logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`) + return { renamed: 0, errors: 0 } + } let renamed = 0 let errors = 0 @@ -506,7 +588,7 @@ export async function syncFilenameChanges( } if (changed) - fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + writeOrganized(organized, organizedPath) if (renamed > 0 || errors > 0) logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) @@ -523,20 +605,24 @@ export async function organizeTorrent( seriesData: any[], files? : { name?: string; progress: number; priority?: number }[], ): Promise { - logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`) + const key = hash.toLowerCase() + const pending = _pendingManual.get(key) + if (pending) { + logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`) + return pending + } + logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : 'lancé'} pour "${name}" (${hash})`) - return new Promise((resolve, reject) => { - const worker = new Worker(resolveWorkerPath()) - // 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: [] } + // 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: [] } - worker.on('message', (msg: any) => { - if (msg.type === 'log') { - logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) - } else if (msg.type === 'result') { + const job = enqueue(async () => { + _pendingManual.delete(key) + try { + await runWorker([fakeTorrent], seriesData, msg => { result.total = msg.total result.skipped = msg.skipped result.done = msg.done @@ -549,21 +635,13 @@ export async function organizeTorrent( } else { logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`) } - } else if (msg.type === 'done') { - worker.terminate() - resolve(result) - } - }) - - worker.on('error', (err) => { - logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`) - worker.terminate() - reject(err) - }) - worker.on('exit', (code) => { - if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`)) - }) - - worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData }) + }) + } catch (err) { + logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`) + throw err + } + return result }) + _pendingManual.set(key, job) + return job } \ No newline at end of file diff --git a/server/routes/import.ts b/server/routes/import.ts index 2350337..f575fb0 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -1,9 +1,8 @@ import { Router } from 'express' import path from 'path' import fs from 'fs' -import { requireAuth } from '../auth.js' +import { requireAuth, requireAdmin } from '../auth.js' import { logger } from '../logger.js' -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' @@ -11,20 +10,10 @@ import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { dispatchRemove } from '../torrent-clients/index.js' import { readRequests, deleteRequest } from '../requests.js' +import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js' const router = Router() -const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') - -function loadOrganizedJson(): Record> { - try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {} - return {} -} - -function saveOrganizedJson(data: Record>) { - fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8') -} - // ── Import manuel ────────────────────────────────────────────── router.post('/manual-import', requireAuth, async (req, res) => { const { serie_id, items } = req.body @@ -32,7 +21,12 @@ router.post('/manual-import', requireAuth, async (req, res) => { res.status(400).json({ error: 'serie_id et items requis' }); return } const { mediaPath, organizeMode, nfoSupport } = readSettings() - const organized = loadOrganizedJson() + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } + // Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin + const ops: ((data: Organized) => void)[] = [] + const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) } let sd = await readSerieData(Number(serie_id)) if (!sd) { // readSerieData ne couvre que le cache local — fallback sur le catalogue complet @@ -78,13 +72,15 @@ router.post('/manual-import', requireAuth, async (req, res) => { // sous un épisode différent (réassignation → l'ancien enregistrement devient invalide) for (const [h, eps] of Object.entries(organized)) { for (const [epId, entry] of Object.entries(eps)) { - if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) { - delete (organized[h] as any)[epId] + if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`) - } } - if (Object.keys(organized[h]).length === 0) delete organized[h] } + apply(data => { + for (const eps of Object.values(data)) + for (const [epId, entry] of Object.entries(eps)) + if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId] + }) fs.mkdirSync(destDir, { recursive: true }) if (file_path !== destPath) { @@ -96,15 +92,17 @@ router.post('/manual-import', requireAuth, async (req, res) => { fs.unlinkSync(destPath) logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`) // Nettoyer toute entrée organized qui pointait sur ce fichier supprimé - for (const [h, eps] of Object.entries(organized)) { + for (const eps of Object.values(organized)) { for (const [epId, entry] of Object.entries(eps)) { - if (entry?.dest_path === destPath) { - delete (organized[h] as any)[epId] + if (entry?.dest_path === destPath) logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`) - } } - if (Object.keys(organized[h]).length === 0) delete organized[h] } + apply(data => { + for (const eps of Object.values(data)) + for (const [epId, entry] of Object.entries(eps)) + if (entry?.dest_path === destPath) delete eps[epId] + }) } if (isInSeriePath && !fs.existsSync(destPath)) { fs.renameSync(file_path, destPath) @@ -125,12 +123,12 @@ router.post('/manual-import', requireAuth, async (req, res) => { logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`) } const torrentHash = String(hash || '').toLowerCase() || 'manual' - if (!organized[torrentHash]) organized[torrentHash] = {} - organized[torrentHash][String(episode_id)] = { + const entry = { at: new Date().toISOString(), season: season.season_number, episode: ep.episode_number, episode_id: ep.id, src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath, } + apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry }) done.push(episode_id) } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' @@ -138,7 +136,13 @@ router.post('/manual-import', requireAuth, async (req, res) => { logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`) } } - saveOrganizedJson(organized) + if (ops.length > 0) { + try { updateOrganized(data => { for (const op of ops) op(data) }) } + catch (err) { + logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`) + res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return + } + } if (nfoSupport && done.length > 0) { const gitlabTitle = getGitlabTitle(serieTitle) @@ -175,7 +179,7 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => { try { const sd = await readSerieData(serieId) if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } - const organized = loadOrganizedJson() + const organized = readOrganized() const { nfoSupport } = readSettings() const result: Record = {} for (const season of sd.seasons ?? []) { @@ -208,9 +212,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => { const { serie_id, episode_id, torrent_hash } = req.body if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return } const { nfoSupport } = readSettings() - const organized = loadOrganizedJson() const sd = await readSerieData(Number(serie_id)) if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } let foundEp: any = null for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { @@ -238,7 +244,7 @@ router.post('/rename-episode', requireAuth, async (req, res) => { if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`) fs.renameSync(oldPath, newPath) organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } - saveOrganizedJson(organized) + writeOrganized(organized) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName }) } catch (err) { @@ -252,9 +258,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => { const serieId = String(req.params.serieId) const deleteFile = req.query.deleteFile === 'true' - const organized = loadOrganizedJson() const sd = await readSerieData(Number(serieId)) if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } const episodeIds = new Set() for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id)) @@ -279,7 +287,7 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => { } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) } } - saveOrganizedJson(organized) + writeOrganized(organized) if (deleteFile) { for (const hash of emptyHashes) { if (hash === 'manual') continue @@ -312,9 +320,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, const serieId = String(req.params.serieId) const seasonId = Number(req.params.seasonId) const deleteFile = req.query.deleteFile === 'true' - const organized = loadOrganizedJson() const sd = await readSerieData(Number(serieId)) if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } const season = sd.seasons?.find((s: any) => s.id === seasonId) if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } const episodeIds = new Set(season.episodes?.map((e: any) => String(e.id)) ?? []) @@ -333,7 +343,7 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) } } - saveOrganizedJson(organized) + writeOrganized(organized) if (deleteFile) { for (const hash of emptyHashes) { if (hash === 'manual') continue @@ -348,27 +358,40 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => { const episodeId = String(req.params.episodeId) const deleteFile = req.query.deleteFile === 'true' - const organized = loadOrganizedJson() - let foundHash: string | null = null - let foundEntry: any = null + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } + const found: { hash: string; entry: any }[] = [] for (const [hash, episodes] of Object.entries(organized)) { - if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break } + if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] }) } - if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return } + if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return } try { - if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) { - fs.unlinkSync(foundEntry.dest_path) - logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`) + const deletedPaths = new Set() + if (deleteFile) { + for (const { entry } of found) { + const p = entry?.dest_path + if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue + fs.unlinkSync(p) + deletedPaths.add(p) + logger.info('api', `Désimport + suppression fichier : "${p}"`) + } } - delete organized[foundHash][episodeId] - const torrentEmpty = Object.keys(organized[foundHash]).length === 0 - if (torrentEmpty) delete organized[foundHash] - saveOrganizedJson(organized) - // Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client - if (deleteFile && torrentEmpty && foundHash !== 'manual') { - dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`)) + const emptyHashes: string[] = [] + for (const { hash } of found) { + delete organized[hash][episodeId] + if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) } } - logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`) + writeOrganized(organized) + // Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client + if (deleteFile) { + for (const hash of emptyHashes) { + if (hash === 'manual') continue + dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`)) + } + } + const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : [] + logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`) res.json({ ok: true }) } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' @@ -381,7 +404,7 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => router.get('/organized-summary', requireAuth, async (_req, res) => { try { const { nfoSupport } = readSettings() - const organized = loadOrganizedJson() + const organized = readOrganized() const seriesData = await loadEnrichedSeriesData() const result: any[] = [] for (const sd of seriesData) { @@ -421,15 +444,18 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { }) // ── Rename en masse ──────────────────────────────────────────── -router.post('/rename-all', requireAuth, async (req, res) => { - const { serie_id } = req.body +router.post('/rename-all', requireAdmin, async (req, res) => { + const { serie_id, serie_ids } = req.body + const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null const { nfoSupport } = readSettings() - const organized = loadOrganizedJson() const seriesData = await loadEnrichedSeriesData() + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } const done: number[] = [] const errors: { episode_id: number; error: string }[] = [] for (const sd of seriesData) { - if (serie_id && sd.id !== Number(serie_id)) continue + if (onlyIds && !onlyIds.has(sd.id)) continue for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { let orgEntry: any = null @@ -465,7 +491,7 @@ router.post('/rename-all', requireAuth, async (req, res) => { } } } - saveOrganizedJson(organized) + writeOrganized(organized) res.json({ ok: true, done: done.length, errors }) }) diff --git a/server/routes/rss-sync.ts b/server/routes/rss-sync.ts index fe559d4..d7ab674 100644 --- a/server/routes/rss-sync.ts +++ b/server/routes/rss-sync.ts @@ -1,7 +1,7 @@ import { Router } from 'express' -import { requireAuth } from '../auth.js' +import { requireAuth, requireAdmin } from '../auth.js' import { logger } from '../logger.js' -import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js' +import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync } from '../lib/rss-sync.js' const router = Router() @@ -21,6 +21,18 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => { } }) +/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */ +router.post('/rss-sync/bulk', requireAdmin, (req, res) => { + const enabled = req.body?.enabled === true + const series = (Array.isArray(req.body?.series) ? req.body.series : []) + .map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') })) + .filter((s: { id: number; name: string }) => s.id && s.name) + if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return } + const { changed, map } = setSyncBulk(series, enabled) + logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`) + res.json({ ok: true, changed, total: Object.keys(map).length }) +}) + /** GET /api/rss-sync/:id → statut sync pour une série */ router.get('/rss-sync/:id', requireAuth, (req, res) => { const serieId = Number(req.params.id) diff --git a/server/routes/series.ts b/server/routes/series.ts index 04c494c..13e28c3 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js' import { readSettings } from '../settings.js' import { dispatchList } from '../torrent-clients/index.js' import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js' +import { loadSynced } from '../lib/rss-sync.js' import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, @@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => { 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 activeTorrents = new Set() - try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {} + const clientHashes = new Set() + try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (!t.hash) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {} + const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps))) + const synced = loadSynced() const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) const serieDataMap = new Map() await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) })) res.json({ series: seriesRaw.map((serie: any) => { - const serieData = serieDataMap.get(serie.id) ?? null - const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false - return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } + const serieData = serieDataMap.get(serie.id) ?? null + const torrents = serieData ? extractTorrentsFromSerieData(serieData) : [] + const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase())) + const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id)))) + return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] } })}) } catch (err) { logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`) diff --git a/server/routes/system.ts b/server/routes/system.ts index 95f33e8..aabba76 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, syncFilenameChanges } from '../organize.js' +import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } 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' @@ -126,18 +126,34 @@ router.get('/torrents/status', requireAuth, async (_req, res) => { } catch { res.json({ exists: false, count: 0, empty: true }) } }) -router.post('/update', requireAuth, async (_req, res) => { +router.post('/update', requireAuth, async (req, res) => { + const force = req.body?.force === true + const organizedPath = path.join(DATA_DIR, 'organized.json') try { cacheClear() - logger.info('api', 'Cache GitHub vidé — rechargement forcé') - const availableIds = await readAvailable() + logger.info('api', force + ? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés' + : 'Cache GitHub vidé — rechargement forcé') + + const availableIds = await readAvailable(force) if (!Array.isArray(availableIds)) throw new Error('available.json invalide') logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) + + if (force) { + const seriesData = await loadEnrichedSeriesData(true) + const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) + const { removed } = dedupeOrganizedEpisodes(organizedPath) + const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath) + logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) + res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors }) + return + } + res.json({ ok: true, count: availableIds.length }) ;(async () => { try { const seriesData = await loadEnrichedSeriesData() - const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) + const { renamed } = await syncFilenameChanges(seriesData, organizedPath) if (renamed > 0) logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) } catch (err) { diff --git a/src/components/series/SeriesToolbar.vue b/src/components/series/SeriesToolbar.vue index 5f1d964..cd640fc 100644 --- a/src/components/series/SeriesToolbar.vue +++ b/src/components/series/SeriesToolbar.vue @@ -75,6 +75,18 @@ + + + @@ -121,7 +133,7 @@