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 @@