Files
FanKarr/server/organize-worker.ts
T
MasutaYunikon d5c37e8f2c 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
2026-03-23 04:04:12 +01:00

360 lines
16 KiB
TypeScript

/**
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] }
* Messages envoyés :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
*/
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
}
// ─── Lookup torrent dans les seriesData passées via message ───
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
for (const sd of seriesData) {
// Niveau série
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: t, serieData: sd }
}
// Niveau saison
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd }
}
// Niveau épisode
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
}
}
}
}
return null
}
// Construit la map filename → { season_number, original_filename }
// depuis les paths[] de la série en utilisant l'index du torrent
function buildFileMap(serieData: any, torrentIdx: number, seasonFilter?: number): Map<string, { season_number: number; original_filename: string }> {
const map = new Map<string, { season_number: number; original_filename: string }>()
for (const season of serieData.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[torrentIdx]
if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath
// original_filename vient de l'API Fankai (passé dans ep directement si disponible)
const original = ep.original_filename ?? filename
map.set(filename, { season_number: season.season_number, original_filename: original })
}
}
return map
}
// ─── Dossiers exclus ─────────────────────────────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
'images', 'image', 'scans', 'scan', 'soundtrack', 'music',
])
function isInExcludedFolder(filePath: string[]): boolean {
for (const folder of filePath.slice(0, -1)) {
const f = folder.toLowerCase().trim()
if (EXCLUDED_FOLDERS.has(f)) return true
for (const excl of EXCLUDED_FOLDERS) {
if (f.startsWith(excl)) return true
}
}
return false
}
// ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
async function fetchJson(url: string): Promise<any> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
return res.json()
}
async function fetchBinary(url: string): Promise<Buffer> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
return Buffer.from(await res.arrayBuffer())
}
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const folderPath = `pack/${serieTitle}`
log(`[nfo] Récupération dossier GitLab: ${serieTitle}`)
let files: any[] = []
try {
let page = 1
while (true) {
const batch = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
)
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch)
if (batch.length < 100) break
page++
}
} catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`)
return
}
if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) {
const relativePath = entry.path.replace(`pack/${serieTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
if (fs.existsSync(destPath)) continue
try {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(serieTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`)
} catch (err) {
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
}
}
log(`[nfo] GitLab synchronisé pour "${serieTitle}"`)
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string { return `Saison ${n}` }
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
return false
}
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
throw new Error(`Torrent introuvable : ${hash}`)
}
const { torrent, serieData } = found
const serieTitle = serieData.title ?? serieData.show_title ?? name
log(`[organize] ✓ Trouvé : ${torrent.title ?? name} — série: ${serieTitle}`)
// Déterminer l'index du torrent dans sa hiérarchie
let torrentIdx = 0
let seasonFilter: number | undefined
if (torrent._episode) {
// Torrent niveau épisode : paths[0] est le seul fichier
torrentIdx = (torrent._episode.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
seasonFilter = torrent._season?.season_number
} else if (torrent._season) {
torrentIdx = (torrent._season.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
seasonFilter = torrent._season.season_number
} else {
torrentIdx = (serieData.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
}
// Construire la map filename → { season_number, original_filename }
const fileMap = buildFileMap(serieData, torrentIdx, seasonFilter)
// ── NFO/images depuis GitLab ──────────────────────────────
if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle)
await downloadGitlabFolder(serieTitle, serieDestRoot)
}
// ── Cas torrent épisode unique ────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
const filePath = (ep.paths ?? [])[torrentIdx]
const filename = filePath?.split('/').pop() ?? name
const original = ep.original_filename ?? filename
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, original)
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
const candidates = [
path.join(savePath, name, filename),
path.join(savePath, filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`[organize] ✗ ${filename} introuvable`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
}
markOrganized(hash, filename)
log(`[organize] ✓ ${original} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
// ── Pack saison / intégrale : itérer sur les fichiers ─────
if (fileMap.size === 0) {
warn(`[organize] Aucun fichier mappé pour ${name}`)
return result
}
result.total = fileMap.size
for (const [filename, { season_number, original_filename }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue }
const candidates = [
path.join(savePath, name, filename),
path.join(savePath, filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`[organize] ✗ ${filename} introuvable`)
result.errors.push({ file: filename, error: 'Source introuvable' })
continue
}
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
const dest = path.join(destDir, original_filename)
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const found = findTorrentByHash(t.hash, seriesData)
if (!found) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
continue
}
// Vérifier si déjà tout organisé
const { serieData } = found
const torrentIdx = (serieData.torrents ?? []).findIndex((st: any) => st.infohash?.toLowerCase() === t.hash.toLowerCase())
const fileMap = buildFileMap(serieData, torrentIdx)
const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name]
if (allDone) continue
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
}
}
parentPort?.postMessage({ type: 'done' })
})