feat: actions de masse sur les séries et fiabilisation des imports et correction protection admin

This commit is contained in:
ElPouki committed 2026-09-11 15:10:40 +02:00
1 parent 7479ddf8eb
commit 7f06dd654a
19 files changed
+636 -257

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ config
.env .env
.env.* .env.*
!.env.example !.env.example
.test-sandbox
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+5
View File
@@ -27,3 +27,8 @@ binaries
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Instance de test locale
.test-sandbox
scripts/seed-test-sandbox.mjs
docker-compose.test.yml
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fankarr</title> <title>Fankarr</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+30 -25
View File
@@ -14,10 +14,11 @@ import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js' import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js' import realDebridDriver from './torrent-clients/real-debrid.js'
import delugeDriver from './torrent-clients/deluge.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 { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js' import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.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 { pushNotif } from './lib/notifs.js'
import { readRequests, completeRequest } from './requests.js' import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js' import { checkNfoUpdates } from './lib/nfo.js'
@@ -91,6 +92,9 @@ const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients', '/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import', '/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync', '/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) => { app.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) { 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}`) 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() const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan // Migration organized.json : si ancien format (valeurs string) → reset + rescan
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) const isOldFormat = Object.values(raw).some((entries: any) =>
const isOldFormat = Object.values(raw).some((entries: any) => Object.values(entries).some(v => typeof v === 'string')
Object.values(entries).some(v => typeof v === 'string') )
) if (isOldFormat) {
if (isOldFormat) { logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation') writeOrganized({})
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
}
} }
} 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 // Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) let patched = 0
let patched = 0 for (const episodes of Object.values(raw)) {
for (const episodes of Object.values(raw)) { for (const entry of Object.values(episodes)) {
for (const entry of Object.values(episodes)) { if (!entry.dest_dir && entry.dest_path) {
if (!entry.dest_dir && entry.dest_path) { entry.dest_dir = path.dirname(entry.dest_path)
entry.dest_dir = path.dirname(entry.dest_path) patched++
patched++
}
} }
} }
if (patched > 0) { }
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2)) if (patched > 0) {
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`) writeOrganized(raw)
} logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
} }
} catch (err) { } catch (err) {
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
@@ -177,6 +178,7 @@ server.listen(PORT, async () => {
loadEnrichedSeriesData() loadEnrichedSeriesData()
.then(async seriesData => { .then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
}) })
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) .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) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0) if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`) 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) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`) logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
+21 -13
View File
@@ -26,34 +26,42 @@ export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size } export function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> { /**
const cached = cacheGet(urlPath) * @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
if (cached) return cached * (~5 min).
const res = await fetch(`${GITHUB_BASE}/${urlPath}`) */
export async function githubGet(urlPath: string, force = false): Promise<any> {
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}`) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json() const data = await res.json()
cacheSet(urlPath, data) cacheSet(urlPath, data)
return data return data
} }
export async function readAvailable(): Promise<number[]> { export async function readAvailable(force = false): Promise<number[]> {
try { return await githubGet('available.json') as number[] } try { return await githubGet('available.json', force) as number[] }
catch { return [] } catch { return [] }
} }
export async function readInfohashMap(): Promise<Record<string, string>> { export async function readInfohashMap(force = false): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> } try { return await githubGet('infohash_map.json', force) as Record<string, string> }
catch { return {} } catch { return {} }
} }
export async function readSerieData(serieId: number): Promise<any | null> { export async function readSerieData(serieId: number, force = false): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) } try { return await githubGet(`series/${serieId}.json`, force) }
catch { return null } catch { return null }
} }
export async function loadEnrichedSeriesData(): Promise<any[]> { export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
const ids = await readAvailable() const ids = await readAvailable(force)
const allSd = await Promise.all(ids.map(id => readSerieData(id))) const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
return allSd.filter(Boolean) return allSd.filter(Boolean)
} }
+43
View File
@@ -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<string, Record<string, any>>
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)
}
}
}
+12
View File
@@ -53,6 +53,18 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
return map 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 { export function isSynced(serieId: number): boolean {
return !!loadSynced()[serieId] return !!loadSynced()[serieId]
} }
+11 -15
View File
@@ -10,8 +10,7 @@ import path from 'path'
import { parentPort } from 'worker_threads' import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js' import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ──────────────────────────────────────────────── // ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
@@ -44,22 +43,12 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode
} }
// ─── Organized log ──────────────────────────────────────────── // ─── 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) { function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized() parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
if (!data[hash]) data[hash] = {}
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
} }
function isOrganized(hash: string, episodeId: number): boolean { function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)] return !!readOrganized()[hash]?.[String(episodeId)]
} }
// ─── Lookup torrent dans les seriesData ─────────────────────── // ─── Lookup torrent dans les seriesData ───────────────────────
@@ -550,9 +539,16 @@ parentPort?.on('message', async (msg: any) => {
const torrents: any[] = msg.torrents const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? [] const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings() 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) { for (const t of torrents) {
if (t.state !== 'seeding') continue if (t.state !== 'seeding') continue
+176 -98
View File
@@ -8,10 +8,8 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js' import { readSettings } from './settings.js'
import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
export let workerRunning = false export let workerRunning = false
const _prevStates = new Map<string, string>() const _prevStates = new Map<string, string>()
@@ -30,18 +28,54 @@ export interface OrganizeResult {
errors : { file: string; error: string }[] errors : { file: string; error: string }[]
} }
interface OrgEntry { type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
at : string
season : number // ── File d'attente des workers ────────────────────────────────
episode : number let _queue: Promise<unknown> = Promise.resolve()
episode_id : number let _queued = 0
src_filename : string const _pendingManual = new Map<string, Promise<OrganizeResult>>()
dest_filename: string
dest_path : string function enqueue<T>(job: () => Promise<T>): Promise<T> {
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage _queued++
const run = _queue.then(job).finally(() => { _queued-- })
_queue = run.catch(() => {})
return run
} }
type Organized = Record<string, Record<string, OrgEntry>> function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
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 ────────────────────────────────────────────── // ── Scan initial ──────────────────────────────────────────────
@@ -65,10 +99,8 @@ export async function scanMediaPath(
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) 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<string, { const filenameIndex = new Map<string, {
hash : string hashes : string[]
srcFilename : string srcFilename : string
episodeId : number episodeId : number
season : number season : number
@@ -84,8 +116,13 @@ export async function scanMediaPath(
const idx = (filename: string, hash: string, destFilename?: string) => { const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '') const base = filename.replace(/\.[^.]+$/, '')
if (!base) return 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, { filenameIndex.set(base, {
hash, hashes : [hash],
srcFilename : filename, srcFilename : filename,
episodeId : ep.id, episodeId : ep.id,
season : season.season_number, season : season.season_number,
@@ -123,11 +160,12 @@ export async function scanMediaPath(
} }
} }
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return result
}
// Track les paires hash:episodeId présentes // Track les paires hash:episodeId présentes
const presentFiles = new Set<string>() const presentFiles = new Set<string>()
@@ -167,7 +205,8 @@ export async function scanMediaPath(
continue 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}`) presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue if (organized[hash]?.[String(episodeId)]) continue
@@ -190,11 +229,17 @@ export async function scanMediaPath(
walk(mediaPath) 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 // 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 let removed = 0
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
if (mediaEmpty) break
for (const episodeId of Object.keys(episodes)) { for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) { if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId] delete organized[hash][episodeId]
@@ -207,7 +252,7 @@ export async function scanMediaPath(
// Désimport automatique des fichiers manquants sur le disque // Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0 let autoRemoved = 0
if (autoUnimportMissing) { if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) { for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path const destPath = entry?.dest_path
@@ -222,7 +267,7 @@ export async function scanMediaPath(
} }
if (result.added > 0 || removed > 0 || autoRemoved > 0) { 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` : ''}`) 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 return
} }
if (workerRunning) { if (_queued > 0) {
logger.debug('organize', 'Worker déjà en cours — skip') logger.debug('organize', 'Import déjà en cours — skip')
return return
} }
@@ -268,11 +313,12 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state) for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized() }
if (fs.existsSync(ORGANIZED_PATH)) catch (err) {
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return
}
const hasUnorganized = torrents.some(t => const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()] 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)`) logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
} }
workerRunning = true try {
const worker = new Worker(resolveWorkerPath()) await enqueue(() => runWorker(torrents, seriesData, msg => {
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') {
if (msg.errors.length > 0) { if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`) logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) { for (const e of msg.errors) {
@@ -316,24 +357,11 @@ export async function autoOrganizeAll(
errors : msg.errors.length, errors : msg.errors.length,
errorFiles: msg.errors, errorFiles: msg.errors,
}) })
} else if (msg.type === 'done') { }))
logger.debug('organize', 'Worker terminé') logger.debug('organize', 'Worker terminé')
workerRunning = false } catch (err) {
worker.terminate() logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
} }
})
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 })
} }
// ── Migration des IDs épisodes ──────────────────────────────── // ── Migration des IDs épisodes ────────────────────────────────
@@ -368,11 +396,12 @@ export async function migrateOrganizedEpisodeIds(
} }
// 2. Charger organized.json // 2. Charger organized.json
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { updated: 0, orphaned: 0 } } return { updated: 0, orphaned: 0 }
}
let updated = 0 let updated = 0
let orphaned = 0 let orphaned = 0
@@ -422,7 +451,7 @@ export async function migrateOrganizedEpisodeIds(
} }
if (changed) { 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` : ''}`) logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else { } else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`) 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 } 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<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
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 ──── // ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges( export async function syncFilenameChanges(
@@ -438,11 +519,12 @@ export async function syncFilenameChanges(
organizedPath: string, organizedPath: string,
): Promise<{ renamed: number; errors: number }> { ): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { renamed: 0, errors: 0 } } return { renamed: 0, errors: 0 }
}
let renamed = 0 let renamed = 0
let errors = 0 let errors = 0
@@ -506,7 +588,7 @@ export async function syncFilenameChanges(
} }
if (changed) if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0) if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
@@ -523,20 +605,24 @@ export async function organizeTorrent(
seriesData: any[], seriesData: any[],
files? : { name?: string; progress: number; priority?: number }[], files? : { name?: string; progress: number; priority?: number }[],
): Promise<OrganizeResult> { ): Promise<OrganizeResult> {
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) => { // files : progression par fichier récupérée depuis le client torrent.
const worker = new Worker(resolveWorkerPath()) // Permet au worker de ne traiter que les fichiers à 100% et d'éviter
// files : progression par fichier récupérée depuis le client torrent. // les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres. const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
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) => { const job = enqueue(async () => {
if (msg.type === 'log') { _pendingManual.delete(key)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) try {
} else if (msg.type === 'result') { await runWorker([fakeTorrent], seriesData, msg => {
result.total = msg.total result.total = msg.total
result.skipped = msg.skipped result.skipped = msg.skipped
result.done = msg.done result.done = msg.done
@@ -549,21 +635,13 @@ export async function organizeTorrent(
} else { } else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`) logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
} }
} else if (msg.type === 'done') { })
worker.terminate() } catch (err) {
resolve(result) logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
} throw err
}) }
return 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 })
}) })
_pendingManual.set(key, job)
return job
} }
+81 -55
View File
@@ -1,9 +1,8 @@
import { Router } from 'express' import { Router } from 'express'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js' import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.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 { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js' import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js' import { readRequests, deleteRequest } from '../requests.js'
import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js'
const router = Router() const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
// ── Import manuel ────────────────────────────────────────────── // ── Import manuel ──────────────────────────────────────────────
router.post('/manual-import', requireAuth, async (req, res) => { router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body 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 res.status(400).json({ error: 'serie_id et items requis' }); return
} }
const { mediaPath, organizeMode, nfoSupport } = readSettings() 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)) let sd = await readSerieData(Number(serie_id))
if (!sd) { if (!sd) {
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet // 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) // sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
for (const [h, eps] of Object.entries(organized)) { for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) { if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`) 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 }) fs.mkdirSync(destDir, { recursive: true })
if (file_path !== destPath) { if (file_path !== destPath) {
@@ -96,15 +92,17 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.unlinkSync(destPath) fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`) logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé // 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)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath) { if (entry?.dest_path === destPath)
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`) 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)) { if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, 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`) logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
} }
const torrentHash = String(hash || '').toLowerCase() || 'manual' const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {} const entry = {
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number, at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id, episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath, src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
} }
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id) done.push(episode_id)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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}`) 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) { if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle) const gitlabTitle = getGitlabTitle(serieTitle)
@@ -175,7 +179,7 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
try { try {
const sd = await readSerieData(serieId) const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson() const organized = readOrganized()
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const result: Record<string, any> = {} const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) { 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 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 } if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id)) const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } 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 let foundEp: any = null
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { 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}`) if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } 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}"`) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName }) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) { } catch (err) {
@@ -252,9 +258,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
router.delete('/organized/:serieId', requireAuth, async (req, res) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId)) const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } 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<string>() const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id)) 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) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue if (hash === 'manual') continue
@@ -312,9 +320,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId) const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId)) const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } 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) const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? []) const episodeIds = new Set<string>(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) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue 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) => { router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId) const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson() let organized: Organized
let foundHash: string | null = null try { organized = readOrganized() }
let foundEntry: any = null 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)) { 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 { try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) { const deletedPaths = new Set<string>()
fs.unlinkSync(foundEntry.dest_path) if (deleteFile) {
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`) 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 emptyHashes: string[] = []
const torrentEmpty = Object.keys(organized[foundHash]).length === 0 for (const { hash } of found) {
if (torrentEmpty) delete organized[foundHash] delete organized[hash][episodeId]
saveOrganizedJson(organized) if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
// 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}`))
} }
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 }) res.json({ ok: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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) => { router.get('/organized-summary', requireAuth, async (_req, res) => {
try { try {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson() const organized = readOrganized()
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const result: any[] = [] const result: any[] = []
for (const sd of seriesData) { for (const sd of seriesData) {
@@ -421,15 +444,18 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
}) })
// ── Rename en masse ──────────────────────────────────────────── // ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => { router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id } = req.body 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 { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData() 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 done: number[] = []
const errors: { episode_id: number; error: string }[] = [] const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) { 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 season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
let orgEntry: any = null 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 }) res.json({ ok: true, done: done.length, errors })
}) })
+14 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.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() 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 */ /** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => { router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) const serieId = Number(req.params.id)
+10 -4
View File
@@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js' import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js' import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { loadSynced } from '../lib/rss-sync.js'
import { import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
let organized: Record<string, Record<string, any>> = {} let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} 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<string>() const activeTorrents = new Set<string>()
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<string>()
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 seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>() const serieDataMap = new Map<number, any>()
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) })) 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) => { res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } 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) { } catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`) logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
+21 -5
View File
@@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js' import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { systemInfo } from '../system.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 { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js' import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.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 }) } } 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 { try {
cacheClear() cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé') logger.info('api', force
const availableIds = await readAvailable() ? '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') if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) 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 }) res.json({ ok: true, count: availableIds.length })
;(async () => { ;(async () => {
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) { } catch (err) {
+16 -1
View File
@@ -75,6 +75,18 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Sélection multiple -->
<button
v-if="selectable"
@click="emit('update:selecting', !selecting)"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
:class="selecting ? 'border-accent text-accent' : ''"
title="Sélectionner plusieurs séries"
>
<ListChecks :size="15" />
<span class="hidden md:inline text-xs">Sélection</span>
</button>
</div> </div>
<!-- Ligne 2 : tri + taille + count --> <!-- Ligne 2 : tri + taille + count -->
@@ -121,7 +133,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next' import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
defineProps<{ defineProps<{
@@ -136,6 +148,8 @@ defineProps<{
filtersImport : { label: string; value: string; color: string }[] filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[] sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[] legend : { label: string; color: string }[]
selectable? : boolean
selecting? : boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -143,6 +157,7 @@ const emit = defineEmits<{
'update:activeFilter': [value: string] 'update:activeFilter': [value: string]
'update:activeSort' : [value: string] 'update:activeSort' : [value: string]
'update:posterSize' : [value: string] 'update:posterSize' : [value: string]
'update:selecting' : [value: boolean]
}>() }>()
const filterOpen = ref(false) const filterOpen = ref(false)
+4
View File
@@ -46,6 +46,10 @@ export interface Serie {
status: string | null status: string | null
torrent_count: number torrent_count: number
has_torrents: boolean has_torrents: boolean
download_state: 'none' | 'downloading' | 'partial' | 'complete'
in_client: boolean
has_files: boolean
rss_synced: boolean
} }
export interface SerieDetail { export interface SerieDetail {
+54 -29
View File
@@ -405,6 +405,16 @@ function integraleGroupLabels(): string[] {
} }
// Épisodes couverts par une intégrale donnée (hash commun) // Épisodes couverts par une intégrale donnée (hash commun)
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
const organizedEpisodeIds = computed(() => {
const ids = new Set<number>()
for (const season of data.value?.seasons ?? [])
for (const ep of season.episodes)
if (ep.organized) ids.add(ep.id)
return ids
})
function integraleEpisodeCoverage(integraleIndex: number): Set<number> { function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
const covered = new Set<number>() const covered = new Set<number>()
const t = data.value?.torrents_integrale[integraleIndex] const t = data.value?.torrents_integrale[integraleIndex]
@@ -503,6 +513,8 @@ async function fetchActiveDownloads() {
const list: any[] = await res.json() const list: any[] = await res.json()
const isFirstPoll = _prevTorrentStates.size === 0 const isFirstPoll = _prevTorrentStates.size === 0
// Un seul import par torrent, même si plusieurs de ses fichiers viennent de se terminer
const toOrganize = new Map<string, any>()
for (const t of list) { for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash) const prevState = _prevTorrentStates.get(t.hash)
@@ -515,7 +527,7 @@ async function fetchActiveDownloads() {
_prevFileProgresses.set(key, f.progress) _prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) { if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(key) _recentlyOrganized.add(key)
triggerOrganize(t) toOrganize.set(t.hash, t)
} }
} }
} }
@@ -523,12 +535,14 @@ async function fetchActiveDownloads() {
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data) // Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) { if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash) _recentlyOrganized.add(t.hash)
triggerOrganize(t) toOrganize.set(t.hash, t)
} }
_prevTorrentStates.set(t.hash, t.state) _prevTorrentStates.set(t.hash, t.state)
} }
for (const t of toOrganize.values()) triggerOrganize(t)
activeTorrents.value = list.map(t => ({ activeTorrents.value = list.map(t => ({
hash : t.hash, hash : t.hash,
progress : t.progress ?? 0, progress : t.progress ?? 0,
@@ -582,6 +596,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
const result: DlItem[] = [] const result: DlItem[] = []
const covered = new Set<number>() const covered = new Set<number>()
// Une autre version déjà envoyée couvre déjà ses épisodes
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
})
// ── 1. Intégrale(s) ─────────────────────────────────────────── // ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }] ? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
@@ -589,15 +609,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const { t, i } of integrales) { for (const { t, i } of integrales) {
if (!t) continue if (!t) continue
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet }) // Épisodes apportés par CETTE intégrale
// Marquer les épisodes couverts par CETTE intégrale uniquement
const hash = t.infohash?.toLowerCase() ?? extractHash(t) const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
if (hash) if (hash)
for (const season of data.value!.seasons) for (const season of data.value!.seasons)
for (const ep of season.episodes) for (const ep of season.episodes)
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash)) if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
covered.add(ep.id) coveredByThis.add(ep.id)
const bringsSomething = coveredByThis.size === 0
|| [...coveredByThis].some(id => !covered.has(id) && !organizedEpisodeIds.value.has(id))
if (bringsSomething && !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
for (const id of coveredByThis) covered.add(id)
} }
// ── 2. Packs saison non couverts ────────────────────────────── // ── 2. Packs saison non couverts ──────────────────────────────
@@ -606,12 +634,13 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale // Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id)) const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue if (!hasUncovered) continue
let addedAny = false const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
season.torrents.forEach((t: any, i: number) => { const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` if (!alreadyHandled) {
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true } const t = season.torrents[0]
}) result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
if (addedAny) for (const ep of season.episodes) covered.add(ep.id) }
for (const ep of season.episodes) covered.add(ep.id)
} }
// ── 3. Épisodes individuels non couverts ───────────────────── // ── 3. Épisodes individuels non couverts ─────────────────────
@@ -625,17 +654,9 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
} }
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer // Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
const hasSomethingToDownload = computed(() => { // Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
if (!data.value) return false // que le garde-fou versions ne laisserait passer aucun torrent.
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
for (const season of data.value.seasons) {
if (season.torrents?.length && season.organized_state !== 'complete' &&
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
for (const ep of season.episodes)
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
}
return false
})
async function downloadAll(integraleIndex?: number) { async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true downloadingAll.value = true
@@ -654,12 +675,16 @@ async function downloadSeason(season: Season, packHash?: string) {
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = [] const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
const seasonAny = season as any const seasonAny = season as any
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') { if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
seasonAny.torrents.forEach((t: any, i: number) => { const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
if (!isAlreadyQueued(t) && !isDownloaded(key)) { if (!alreadyHandled) {
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }) const chosen = packHash
} ? seasonAny.torrents.findIndex((t: any) => (t.infohash ?? extractHash(t)) === packHash)
}) : 0
const i = chosen >= 0 ? chosen : 0
const t = seasonAny.torrents[i]
torrents.push({ key: packKey(i), torrent_url: t.torrent_url, magnet: t.magnet })
}
} else { } else {
for (const ep of seasonAny.episodes ?? []) { for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant // Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
+102 -9
View File
@@ -13,6 +13,8 @@
:filters-import="filtersImport" :filters-import="filtersImport"
:sort-options="sortOptions" :sort-options="sortOptions"
:legend="legend" :legend="legend"
:selectable="auth.isAdmin"
v-model:selecting="selecting"
/> />
<!-- Grille --> <!-- Grille -->
@@ -33,14 +35,22 @@
</div> </div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }"> <div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<RouterLink <component
:is="selecting ? 'div' : RouterLink"
v-for="serie in filtered" v-for="serie in filtered"
:key="serie.id" :key="serie.id"
:to="`/series/${serie.id}`" v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5" class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }" :class="{
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
'cursor-pointer select-none': selecting,
}"
@click="selecting && toggleSelected(serie.id)"
> >
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border"> <div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
>
<img <img
v-if="serie.poster_image" v-if="serie.poster_image"
:src="serie.poster_image" :src="serie.poster_image"
@@ -52,10 +62,22 @@
<Tv :size="28" /> <Tv :size="28" />
</div> </div>
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150"> <div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span> <span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div> </div>
<div
v-if="selecting"
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
>
<Check v-if="selected.has(serie.id)" :size="13" />
</div>
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
<Rss :size="11" />
</div>
<div <div
class="absolute bottom-0 left-0 right-0 h-1" class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }" :style="{ background: stateColor(serie.download_state) }"
@@ -77,7 +99,20 @@
</span> </span>
</div> </div>
</div> </div>
</RouterLink> </component>
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
<div class="flex items-center gap-2 ml-auto">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
</div> </div>
</div> </div>
@@ -85,10 +120,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Tv } from 'lucide-vue-next' import { Tv, Check, Rss, X } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series' import { useSeriesStore, type Serie } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -117,6 +152,7 @@ const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' }, { label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' }, { label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' }, { label: 'Partiel', value: 'partial', color: '#9b59b6' },
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
] ]
const sortOptions = [ const sortOptions = [
@@ -143,6 +179,7 @@ const filtered = computed(() => {
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete') if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state)) if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial') if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
if (search.value.trim()) { if (search.value.trim()) {
const q = search.value.toLowerCase() const q = search.value.toLowerCase()
@@ -163,6 +200,62 @@ const filtered = computed(() => {
return list return list
}) })
// ── Sélection multiple ───────────────────────────────────────
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
const bulkBusy = ref(false)
watch(selecting, on => { if (!on) clearSelection() })
function toggleSelected(id: number) {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
function selectAllFiltered() { selected.value = new Set(filtered.value.map(s => s.id)) }
function clearSelection() { selected.value = new Set() }
async function bulkRename() {
bulkBusy.value = true
try {
const res = await fetch('/api/rename-all', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serie_ids: [...selected.value] }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
const errs = data.errors?.length ?? 0
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
async function bulkSync(enabled: boolean) {
bulkBusy.value = true
try {
const targets = store.series.filter((s: Serie) => selected.value.has(s.id))
const res = await fetch('/api/rss-sync/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
for (const s of targets) s.rss_synced = enabled
const n = data.changed
toast(enabled
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
function stateColor(state: string): string { function stateColor(state: string): string {
return ({ return ({
complete : '#22c55e', complete : '#22c55e',
@@ -22,6 +22,14 @@
</svg> </svg>
Actualiser Actualiser
</button> </button>
<button
@click="forceMetadataRefresh"
:disabled="forcingRefresh"
class="btn-secondary"
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
>
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
</button>
<button <button
@click="purgeNfo" @click="purgeNfo"
:disabled="purgingNfo" :disabled="purgingNfo"
@@ -177,6 +185,7 @@ const { add: toast } = useToast()
const loading = ref(true) const loading = ref(true)
const renamingAll = ref(false) const renamingAll = ref(false)
const purgingNfo = ref(false) const purgingNfo = ref(false)
const forcingRefresh = ref(false)
const series = ref<any[]>([]) const series = ref<any[]>([])
const nfoSupport = ref(false) const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set()) const collapsed = ref<Set<number>>(new Set())
@@ -254,6 +263,30 @@ async function renameSingle(ep: any, serie: any) {
} }
} }
async function forceMetadataRefresh() {
forcingRefresh.value = true
try {
const res = await fetch('/api/update', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
forcingRefresh.value = false
}
}
async function purgeNfo() { async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true purgingNfo.value = true