657 lines
27 KiB
TypeScript
657 lines
27 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 { readSettings } from './settings.js'
|
|
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
|
|
|
|
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 }[]
|
|
}
|
|
|
|
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
|
|
|
|
// ── File d'attente des workers ────────────────────────────────
|
|
let _queue: Promise<unknown> = Promise.resolve()
|
|
let _queued = 0
|
|
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
|
|
|
|
function enqueue<T>(job: () => Promise<T>): Promise<T> {
|
|
_queued++
|
|
const run = _queue.then(job).finally(() => { _queued-- })
|
|
_queue = run.catch(() => {})
|
|
return run
|
|
}
|
|
|
|
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 ──────────────────────────────────────────────
|
|
|
|
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}`)
|
|
|
|
const filenameIndex = new Map<string, {
|
|
hashes : string[]
|
|
srcFilename : string
|
|
episodeId : number
|
|
season : number
|
|
episode : number
|
|
destFilename: string
|
|
}>()
|
|
|
|
for (const sd of seriesData) {
|
|
for (const season of sd.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
|
|
// Helper : indexer par nom sans extension
|
|
const idx = (filename: string, hash: string, destFilename?: string) => {
|
|
const base = filename.replace(/\.[^.]+$/, '')
|
|
if (!base) return
|
|
const existing = filenameIndex.get(base)
|
|
if (existing && existing.episodeId === ep.id) {
|
|
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
|
|
return
|
|
}
|
|
filenameIndex.set(base, {
|
|
hashes : [hash],
|
|
srcFilename : filename,
|
|
episodeId : ep.id,
|
|
season : season.season_number,
|
|
episode : ep.episode_number,
|
|
destFilename: destFilename ?? filename,
|
|
})
|
|
}
|
|
|
|
// 1. Noms depuis les paths (avec vrai infohash)
|
|
for (const p of ep.paths ?? []) {
|
|
if (typeof p !== 'object' || !p.infohash || !p.path) continue
|
|
const hash = p.infohash.toLowerCase()
|
|
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
|
if (!srcFilename) continue
|
|
idx(srcFilename, hash)
|
|
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
|
|
const pathNfo = p.nfo_filename ?? ep.nfo_filename
|
|
if (pathNfo) idx(pathNfo, hash, pathNfo)
|
|
const pathFmt = p.formatted_name ?? ep.formatted_name
|
|
if (pathFmt?.trim()) {
|
|
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
|
|
idx(fmtBase, hash, fmtBase)
|
|
}
|
|
}
|
|
|
|
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
|
|
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
|
|
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
|
|
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
|
|
if (ep.formatted_name?.trim()) {
|
|
const fmtBase = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
|
|
idx(fmtBase, fallbackHash, fmtBase)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let organized: Organized
|
|
try { organized = readOrganized(organizedPath) }
|
|
catch (err) {
|
|
logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
|
|
return result
|
|
}
|
|
|
|
// Track les paires hash:episodeId présentes
|
|
const presentFiles = new Set<string>()
|
|
let noMatch = 0
|
|
|
|
const trackedByPath = new Map<string, string[]>()
|
|
for (const [hash, episodes] of Object.entries(organized)) {
|
|
for (const [episodeId, entry] of Object.entries(episodes)) {
|
|
if (!entry?.dest_path) continue
|
|
const keys = trackedByPath.get(entry.dest_path) ?? []
|
|
keys.push(`${hash}:${episodeId}`)
|
|
trackedByPath.set(entry.dest_path, keys)
|
|
}
|
|
}
|
|
|
|
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() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
|
result.found++
|
|
|
|
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
|
|
const tracked = trackedByPath.get(full)
|
|
if (tracked) {
|
|
for (const key of tracked) presentFiles.add(key)
|
|
continue
|
|
}
|
|
|
|
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
|
|
let match = filenameIndex.get(nameWithoutExt)
|
|
|
|
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
|
|
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
|
|
if (!match) {
|
|
const lower = nameWithoutExt.toLowerCase()
|
|
for (const [key, val] of filenameIndex.entries()) {
|
|
if (lower.startsWith(key.toLowerCase() + '.') || lower === key.toLowerCase()) {
|
|
match = val
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!match) {
|
|
noMatch++
|
|
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
|
|
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
|
|
const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
|
|
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
|
|
continue
|
|
}
|
|
|
|
const { hashes, srcFilename, episodeId, season, episode } = match
|
|
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
|
|
presentFiles.add(`${hash}:${episodeId}`)
|
|
|
|
if (organized[hash]?.[String(episodeId)]) continue
|
|
|
|
if (!organized[hash]) organized[hash] = {}
|
|
organized[hash][String(episodeId)] = {
|
|
at : new Date().toISOString(),
|
|
season,
|
|
episode,
|
|
episode_id : episodeId,
|
|
src_filename : srcFilename,
|
|
dest_filename: entry.name, // vrai nom sur disque
|
|
dest_path : full,
|
|
}
|
|
result.added++
|
|
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(mediaPath)
|
|
|
|
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
|
|
if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
|
|
|
|
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
|
|
const allEpisodeIds = new Set(
|
|
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
|
|
)
|
|
|
|
let removed = 0
|
|
for (const [hash, episodes] of Object.entries(organized)) {
|
|
if (mediaEmpty) break
|
|
for (const episodeId of Object.keys(episodes)) {
|
|
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
|
|
delete organized[hash][episodeId]
|
|
removed++
|
|
}
|
|
}
|
|
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
|
}
|
|
|
|
// Désimport automatique des fichiers manquants sur le disque
|
|
const { autoUnimportMissing } = readSettings() as any
|
|
let autoRemoved = 0
|
|
if (autoUnimportMissing && !mediaEmpty) {
|
|
for (const [hash, episodes] of Object.entries(organized)) {
|
|
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
|
const destPath = entry?.dest_path
|
|
if (destPath && !fs.existsSync(destPath)) {
|
|
delete (organized[hash] as any)[episodeId]
|
|
autoRemoved++
|
|
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
|
|
}
|
|
}
|
|
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
|
}
|
|
}
|
|
|
|
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
|
|
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` : ''}`)
|
|
|
|
return result
|
|
}
|
|
|
|
// ── Auto-import ───────────────────────────────────────────────
|
|
|
|
export async function autoOrganizeAll(
|
|
listFn : () => Promise<any[]>,
|
|
seriesData : any[],
|
|
onResult ?: (r: { hash: string; name: string; serieId?: number | null; 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 (_queued > 0) {
|
|
logger.debug('organize', 'Import 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: Organized
|
|
try { organized = readOrganized() }
|
|
catch (err) {
|
|
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
|
|
return
|
|
}
|
|
|
|
const hasUnorganized = torrents.some(t =>
|
|
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
|
|
)
|
|
|
|
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)`)
|
|
}
|
|
|
|
try {
|
|
await enqueue(() => runWorker(torrents, seriesData, msg => {
|
|
if (msg.errors.length > 0) {
|
|
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
|
for (const e of msg.errors) {
|
|
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,
|
|
serieId : msg.serieId ?? null,
|
|
done : msg.done,
|
|
skipped : msg.skipped,
|
|
errors : msg.errors.length,
|
|
errorFiles: msg.errors,
|
|
})
|
|
}))
|
|
logger.debug('organize', 'Worker terminé')
|
|
} catch (err) {
|
|
logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
|
|
// ── Migration des IDs épisodes ────────────────────────────────
|
|
//
|
|
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
|
|
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
|
|
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
|
|
|
|
export async function migrateOrganizedEpisodeIds(
|
|
organizedPath: string,
|
|
seriesData : any[]
|
|
): Promise<{ updated: number; orphaned: number }> {
|
|
|
|
// 1. Construire les tables de lookup depuis le catalogue actuel
|
|
// hashEpMap : infohash → Map<"S:E", new_episode_id>
|
|
const hashEpMap = new Map<string, Map<string, number>>()
|
|
const validIds = new Set<number>()
|
|
|
|
for (const sd of seriesData) {
|
|
for (const season of sd.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
validIds.add(ep.id)
|
|
for (const p of ep.paths ?? []) {
|
|
if (typeof p !== 'object' || !p.infohash) continue
|
|
const h = p.infohash.toLowerCase()
|
|
const key = `${season.season_number}:${ep.episode_number}`
|
|
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
|
|
hashEpMap.get(h)!.set(key, ep.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Charger organized.json
|
|
let organized: Organized
|
|
try { organized = readOrganized(organizedPath) }
|
|
catch (err) {
|
|
logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
|
|
return { updated: 0, orphaned: 0 }
|
|
}
|
|
|
|
let updated = 0
|
|
let orphaned = 0
|
|
let changed = false
|
|
|
|
for (const [hash, episodes] of Object.entries(organized)) {
|
|
if (hash === 'manual') continue
|
|
const epMap = hashEpMap.get(hash.toLowerCase())
|
|
|
|
const newEpisodes: Record<string, any> = {}
|
|
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
|
|
const oldId = Number(oldIdStr)
|
|
|
|
if (validIds.has(oldId)) {
|
|
// ID encore valide, aucun changement
|
|
newEpisodes[oldIdStr] = entry
|
|
continue
|
|
}
|
|
|
|
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
|
|
const key = `${entry.season}:${entry.episode}`
|
|
const newId = epMap?.get(key)
|
|
|
|
if (newId && newId !== oldId) {
|
|
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
|
|
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
|
|
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
|
|
changed = true
|
|
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
|
} else {
|
|
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
|
|
updated++
|
|
changed = true
|
|
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
|
}
|
|
} else {
|
|
// Introuvable — on conserve pour ne pas perdre l'entrée
|
|
orphaned++
|
|
newEpisodes[oldIdStr] = entry
|
|
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
|
}
|
|
}
|
|
|
|
organized[hash] = newEpisodes
|
|
// Nettoyer les hash vides
|
|
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
|
}
|
|
|
|
if (changed) {
|
|
writeOrganized(organized, organizedPath)
|
|
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
|
|
} else {
|
|
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
|
|
}
|
|
|
|
return { updated, orphaned }
|
|
}
|
|
|
|
// ── Déduplication ─────────────────────────────────────────────
|
|
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 present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
|
|
if (present.length === 0) continue
|
|
|
|
for (const c of candidates) {
|
|
if (present.includes(c)) continue
|
|
delete (organized[c.hash] as any)[episodeId]
|
|
removed++
|
|
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
|
|
}
|
|
}
|
|
|
|
if (removed > 0) {
|
|
writeOrganized(organized, organizedPath)
|
|
logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
|
|
}
|
|
|
|
return { removed }
|
|
}
|
|
|
|
// ── Sync des noms de fichiers après mise à jour du scraper ────
|
|
|
|
export async function syncFilenameChanges(
|
|
seriesData : any[],
|
|
organizedPath: string,
|
|
): Promise<{ renamed: number; errors: number }> {
|
|
const { nfoSupport } = readSettings()
|
|
let organized: Organized
|
|
try { organized = readOrganized(organizedPath) }
|
|
catch (err) {
|
|
logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
|
|
return { renamed: 0, errors: 0 }
|
|
}
|
|
|
|
let renamed = 0
|
|
let errors = 0
|
|
let changed = false
|
|
|
|
for (const sd of seriesData) {
|
|
for (const season of sd.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
let orgEntry: any = null
|
|
let orgHash: string | null = null
|
|
for (const [hash, eps] of Object.entries(organized)) {
|
|
if ((eps as any)[String(ep.id)]) {
|
|
orgEntry = (eps as any)[String(ep.id)]
|
|
orgHash = hash
|
|
break
|
|
}
|
|
}
|
|
if (!orgEntry || !orgHash) continue
|
|
|
|
const currentName = orgEntry.dest_path
|
|
? path.basename(orgEntry.dest_path)
|
|
: orgEntry.dest_filename
|
|
const srcExt = path.extname(currentName)
|
|
// Priorité aux valeurs du path entry correspondant au hash organisé
|
|
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
|
|
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
|
|
)
|
|
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
|
|
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
|
|
const expectedName: string = nfoSupport
|
|
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
|
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
|
|
|
if (expectedName === currentName) continue
|
|
|
|
const oldPath = orgEntry.dest_path
|
|
if (!oldPath || !fs.existsSync(oldPath)) continue
|
|
|
|
const newPath = path.join(path.dirname(oldPath), expectedName)
|
|
try {
|
|
if (fs.existsSync(newPath)) {
|
|
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
|
|
continue
|
|
}
|
|
fs.renameSync(oldPath, newPath)
|
|
;(organized[orgHash] as any)[String(ep.id)] = {
|
|
...orgEntry,
|
|
dest_filename: expectedName,
|
|
dest_path : newPath,
|
|
at : new Date().toISOString(),
|
|
}
|
|
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
|
renamed++
|
|
changed = true
|
|
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
|
|
} catch (err) {
|
|
errors++
|
|
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
writeOrganized(organized, organizedPath)
|
|
|
|
if (renamed > 0 || errors > 0)
|
|
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
|
|
|
return { renamed, errors }
|
|
}
|
|
|
|
// ── Import manuel ─────────────────────────────────────────────
|
|
|
|
export async function organizeTorrent(
|
|
hash : string,
|
|
name : string,
|
|
savePath : string,
|
|
seriesData: any[],
|
|
files? : { name?: string; progress: number; priority?: number }[],
|
|
): Promise<OrganizeResult> {
|
|
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})`)
|
|
|
|
// files : progression par fichier récupérée depuis le client torrent.
|
|
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
|
|
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
|
|
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
|
|
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
|
|
|
const job = enqueue(async () => {
|
|
_pendingManual.delete(key)
|
|
try {
|
|
await runWorker([fakeTorrent], seriesData, msg => {
|
|
result.total = msg.total
|
|
result.skipped = msg.skipped
|
|
result.done = msg.done
|
|
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)`)
|
|
}
|
|
})
|
|
} catch (err) {
|
|
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
|
|
throw err
|
|
}
|
|
return result
|
|
})
|
|
_pendingManual.set(key, job)
|
|
return job
|
|
} |