feat!: migration vers nouvelle architecture API et scraper par série

BREAKING CHANGE: suppression de torrent_final.json au profit d'un système
de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h.
Version majeure — nécessite un nouveau scraper et une mise à jour complète.

feat(api): cache mémoire GitHub TTL 6h avec invalidation via /api/update

Plus de fichiers locaux dans /config pour les torrents. Les données sont
fetchées depuis GitHub (available.json + series/{id}.json) et mises en
cache 6h. /api/update vide le cache pour forcer le rechargement.

feat(api): adaptation complète à la nouvelle structure API Fankai

- normalizeSerie/Season/Episode pour aplatir images.poster → poster_image
- Proxy wsrv.nl pour redimensionner les images (poster 300px, fanart 1280px,
  thumbnail 400px) et réduire le temps de chargement
- duration en secondes → affichage en minutes dans SerieView

feat(organize): refonte complète sans torrent_final.json

- organize-worker.ts reçoit seriesData via message au lieu de lire un fichier
- buildFileMap construit filename→{season_number,original_filename} depuis paths[]
- enrichSeriesDataWithOriginalFilenames fetche /seasons/{id}/episodes avant
  de passer les données au worker pour le renommage correct des fichiers
- Suppression de SERIE_TITLE_GITLAB_MAP (Kaï→Kai résolu côté API)
- scanMediaPath adapté : plus de torrentsPath, prend seriesData en paramètre

feat(api): matching torrents par hiérarchie série/saison/épisode

- Torrents niveau série → pack_integrale
- Torrents niveau saison → pack_saison (indexé par season_number)
- Torrents niveau épisode → episode
- Matching via paths[torrentIdx] pour résoudre episode↔fichier

feat(series-view): badge "Hors Fankai" sur épisodes de torrents manuels
feat(series-view): tooltip raw name sur boutons intégrale via :title
feat(scraper): extraction nom bencode dans raw pour torrents locaux
fix(series): boutons saison Yu-Gi-Oh via fallback season_number
fix(catalogue): état download basé sur épisodes organisés vs torrents bruts

Release-As: 2.0.0
This commit is contained in:
MasutaYunikon committed 2026-03-23 04:04:12 +01:00
1 parent 96ce207027
commit d5c37e8f2c
3 files changed
+590 -515

No files matched your search

+75 -89
View File
@@ -2,27 +2,23 @@
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
* Le worker gère tout le filesystem (copy, hardlink, move).
*/
import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un seul worker à la fois
let workerRunning = false
// État précédent des torrents pour détecter les passages downloading → seeding
const _prevStates = new Map<string, string>() // hash → state
const _prevStates = new Map<string, string>()
// Résolution du chemin du worker
// En binaire Bun compilé : le worker est à côté de l'exécutable
// En Docker/dev : à côté du fichier courant
function resolveWorkerPath(): string {
// Si Bun est présent → le worker est toujours à côté de l'exécutable
// (que ce soit un binaire compilé ou `bun run` en dev)
// En Node/tsx → on utilise import.meta.url
if (typeof (globalThis as any).Bun !== 'undefined') {
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
}
@@ -38,13 +34,12 @@ export interface OrganizeResult {
/**
* Scan initial du mediaPath au démarrage.
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
* tous les fichiers déjà présents qui correspondent à un torrent connu.
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
*/
export async function scanMediaPath(
mediaPath : string,
torrentsPath: string,
organizedPath: string
mediaPath : string,
organizedPath: string,
seriesData : any[] = []
): Promise<{ found: number; added: number }> {
const result = { found: 0, added: 0 }
@@ -53,27 +48,45 @@ export async function scanMediaPath(
return result
}
// Charger torrent_final.json
let torrents: any[] = []
try {
if (!fs.existsSync(torrentsPath)) return result
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
} catch {
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
return result
}
// Construire un index filename → { hash, infohash }
// Construire index filename → infohash depuis les seriesData
const filenameIndex = new Map<string, string>() // filename → infohash
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const f of t.torrent_files ?? []) {
if (f.filename) filenameIndex.set(f.filename, hash)
for (const sd of seriesData) {
// Torrents niveau série
for (const [i, t] of (sd.torrents ?? []).entries()) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[i]
if (!filePath) continue
const filename = filePath.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
}
// Fichier unique (torrent_files vide) → on indexe par raw name
if ((t.torrent_files ?? []).length === 0 && t.raw) {
filenameIndex.set(t.raw, hash)
// Torrents niveau saison
for (const season of sd.seasons ?? []) {
for (const [i, t] of (season.torrents ?? []).entries()) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[i]
if (!filePath) continue
const filename = filePath.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
// Torrents niveau épisode
for (const ep of season.episodes ?? []) {
for (const [i, t] of (ep.torrents ?? []).entries()) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const filePath = (ep.paths ?? [])[i]
if (!filePath) continue
const filename = filePath.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
}
}
@@ -84,10 +97,8 @@ export async function scanMediaPath(
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Construire le set des fichiers présents sur le disque (pour nettoyage)
const presentFiles = new Set<string>() // "hash:filename"
const presentFiles = new Set<string>()
// Parcourir récursivement mediaPath
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -101,8 +112,7 @@ export async function scanMediaPath(
const hash = filenameIndex.get(entry.name)
if (!hash) continue
presentFiles.add(`${hash}:${entry.name}`)
if (organized[hash]?.[entry.name]) continue // déjà connu
// Marquer comme organisé
if (organized[hash]?.[entry.name]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
result.added++
@@ -112,28 +122,21 @@ export async function scanMediaPath(
walk(mediaPath)
// Nettoyer les entrées orphelines (fichiers supprimés)
// Nettoyer les entrées orphelines
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) {
if (!presentFiles.has(`${hash}:${filename}`)) {
// Vérifier que ce hash est bien dans le filenameIndex
// (évite de supprimer des entrées de torrents non-scannés)
if (filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
removed++
}
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
removed++
}
}
// Supprimer l'entrée du torrent si elle est vide
if (Object.keys(organized[hash]).length === 0) {
delete organized[hash]
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (result.added > 0 || removed > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés, ${removed} entrées orphelines supprimées`)
logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
} else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
}
@@ -142,13 +145,12 @@ export async function scanMediaPath(
}
/**
* Lance l'auto-organisation dans un worker thread.
* listFn est appelée dans le thread principal (accès qBittorrent),
* puis les données sont passées au worker.
* Auto-organisation — appelée depuis index.ts avec les seriesData du cache GitHub.
*/
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
listFn : () => Promise<any[]>,
seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> {
if (workerRunning) {
console.log('[organize] Worker déjà en cours, skip')
@@ -165,19 +167,16 @@ export async function autoOrganizeAll(
if (torrents.length === 0) return
const isFirstRun = _prevStates.size === 0
const isFirstRun = _prevStates.size === 0
const seedingCount = torrents.filter(t => t.state === 'seeding').length
// Détecter les torrents qui viennent de passer en seeding (downloading → seeding)
const newlySeeding = torrents.filter(t => {
const prev = _prevStates.get(t.hash)
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
})
// Mettre à jour l'état précédent
for (const t of torrents) _prevStates.set(t.hash, t.state)
// Lancer seulement si : premier run avec des seeders, ou nouveau torrent terminé
if (!isFirstRun && newlySeeding.length === 0) return
if (seedingCount === 0) return
@@ -188,9 +187,7 @@ export async function autoOrganizeAll(
}
workerRunning = true
const workerPath = resolveWorkerPath()
const worker = new Worker(workerPath)
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
@@ -200,12 +197,12 @@ export async function autoOrganizeAll(
} else if (msg.type === 'result') {
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
onResult?.({
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles : msg.errors,
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
console.log('[organize] Worker terminé')
@@ -214,34 +211,23 @@ export async function autoOrganizeAll(
}
})
worker.on('error', (err) => {
console.error('[organize] Worker erreur:', err)
workerRunning = false
})
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false })
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false })
worker.on('exit', (code) => {
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
workerRunning = false
})
// Envoyer les torrents au worker
worker.postMessage({ type: 'run', torrents })
worker.postMessage({ type: 'run', torrents, seriesData })
}
/**
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
* Lance aussi dans un worker thread.
* Organisation manuelle d'un torrent.
*/
export async function organizeTorrent(
hash : string,
name : string,
savePath: string
hash : string,
name : string,
savePath : string,
seriesData: any[]
): Promise<OrganizeResult> {
return new Promise((resolve, reject) => {
const workerPath = resolveWorkerPath()
const worker = new Worker(workerPath)
// On envoie un seul torrent "fictif seeding"
const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
@@ -264,6 +250,6 @@ export async function organizeTorrent(
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
}