Files
FanKarr/server/organize.ts
T
MasutaYunikon 0e09c85efd feat: add uttorent driver
fix: organiser take correct name
2026-04-09 15:59:55 +02:00

316 lines
12 KiB
TypeScript

/**
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
*/
import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
export let workerRunning = false
const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string {
if (typeof (globalThis as any).Bun !== 'undefined') {
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
}
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
}
export interface OrganizeResult {
total : number
skipped : number
done : number
errors : { file: string; error: string }[]
}
// ── Scan initial ──────────────────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
if (!cur || cur === ext) return filename
return filename.slice(0, -cur.length) + ext
}
export async function scanMediaPath(
mediaPath : string,
organizedPath: string,
seriesData : any[] = []
): Promise<{ found: number; added: number }> {
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
return result
}
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
// FIX : on indexe le nom brut ET les noms de destination possibles
// (nfo_filename, formatted_name) pour matcher les fichiers déjà renommés
// Map : nom_sur_disque → { hash, srcFilename }
const filenameIndex = new Map<string, { hash: string; srcFilename: string }>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
for (const p of ep.paths ?? []) {
if (typeof p === 'string') continue
const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
const srcExt = path.extname(srcFilename)
// 1. Nom brut du fichier source
filenameIndex.set(srcFilename, { hash, srcFilename })
// 2. nfo_filename avec extension swappée
if (ep.nfo_filename) {
const nfoRenamed = swapExt(ep.nfo_filename, srcExt)
filenameIndex.set(nfoRenamed, { hash, srcFilename })
}
// 3. original_filename
if (ep.original_filename) {
filenameIndex.set(ep.original_filename, { hash, srcFilename })
}
// 4. formatted_name + .mkv
if (ep.formatted_name?.trim()) {
const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv'
filenameIndex.set(fmtName, { hash, srcFilename })
}
}
}
}
}
let organized: Record<string, Record<string, string>> = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// FIX : on track les paires hash:srcFilename (clé dans organized.json)
// et non hash:nom_sur_disque
const presentFiles = new Set<string>()
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
catch { return }
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
result.found++
const match = filenameIndex.get(entry.name)
if (!match) continue
const { hash, srcFilename } = match
// La clé dans organized.json est toujours le srcFilename (nom brut)
presentFiles.add(`${hash}:${srcFilename}`)
if (organized[hash]?.[srcFilename]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][srcFilename] = new Date().toISOString()
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → hash ${hash.slice(0, 8)}… (src: ${srcFilename})`)
}
}
}
walk(mediaPath)
// FIX : supprimer uniquement les entrées dont le fichier source
// n'est plus présent ET qui sont connues dans l'index
// (évite de supprimer des entrées pour des fichiers hors catalogue)
const allSrcFilenames = new Set([...filenameIndex.values()].map(v => v.srcFilename))
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const srcFilename of Object.keys(files)) {
// Ne supprimer que si le fichier est dans notre catalogue
// ET qu'il n'est plus présent dans la médiathèque
if (allSrcFilenames.has(srcFilename) && !presentFiles.has(`${hash}:${srcFilename}`)) {
delete organized[hash][srcFilename]
removed++
}
}
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', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
} else {
logger.info('organize', `Scan terminé — ${result.found} fichiers, rien de nouveau`)
}
return result
}
// ── Auto-import ───────────────────────────────────────────────
export async function autoOrganizeAll(
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> {
const { autoImport } = readSettings()
if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
return
}
if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours — skip')
return
}
let torrents: any[]
try {
torrents = await listFn()
} catch (err) {
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`)
return
}
if (torrents.length === 0) return
const isFirstRun = _prevStates.size === 0
const seedingCount = torrents.filter(t => t.state === 'seeding').length
const newlySeeding = torrents.filter(t => {
const prev = _prevStates.get(t.hash)
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
})
for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
)
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) {
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
for (const t of newlySeeding) {
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
}
} else {
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
}
workerRunning = true
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
}
} else if (msg.done > 0) {
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
} else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
}
onResult?.({
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
worker.postMessage({ type: 'run', torrents, seriesData })
}
// ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData: any[]
): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
return new Promise((resolve, reject) => {
const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
result.errors = msg.errors
if (msg.errors.length > 0) {
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
}
} else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
}
} else if (msg.type === 'done') {
worker.terminate()
resolve(result)
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
worker.terminate()
reject(err)
})
worker.on('exit', (code) => {
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
}