L'import manuel créait un fakeTorrent sans champ files, ce qui désactivait le filtre de complétion par fichier dans le worker. Sur Windows, tenter de déplacer un fichier encore écrit par qBittorrent provoque EBUSY ; les fichiers échouaient mais étaient quand même marqués importés en mémoire. Fix : - routes/organize.ts : appel dispatchGetFiles(hash) avant l'import pour récupérer la progression réelle par fichier depuis le client torrent - organize.ts : organizeTorrent accepte un param optionnel files[] transmis au worker via fakeTorrent ; sans fichiers (client injoignable), le comportement précédent (pas de filtre) est conservé en fallback
569 lines
23 KiB
TypeScript
569 lines
23 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 }[]
|
|
}
|
|
|
|
interface OrgEntry {
|
|
at : string
|
|
season : number
|
|
episode : number
|
|
episode_id : number
|
|
src_filename : string
|
|
dest_filename: string
|
|
dest_path : string
|
|
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage
|
|
}
|
|
|
|
type Organized = Record<string, Record<string, OrgEntry>>
|
|
|
|
// ── 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}`)
|
|
|
|
// 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, {
|
|
hash : 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
|
|
filenameIndex.set(base, {
|
|
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 {
|
|
if (fs.existsSync(organizedPath))
|
|
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
|
} catch {}
|
|
|
|
// Track les paires hash:episodeId présentes
|
|
const presentFiles = new Set<string>()
|
|
let noMatch = 0
|
|
|
|
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++
|
|
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 { hash, srcFilename, episodeId, season, episode } = match
|
|
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)
|
|
|
|
// 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}`))
|
|
|
|
let removed = 0
|
|
for (const [hash, episodes] of Object.entries(organized)) {
|
|
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) {
|
|
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) {
|
|
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${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 (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: Organized = {}
|
|
try {
|
|
if (fs.existsSync(ORGANIZED_PATH))
|
|
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, '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,
|
|
serieId : msg.serieId ?? null,
|
|
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 })
|
|
}
|
|
|
|
// ── 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 {
|
|
if (fs.existsSync(organizedPath))
|
|
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
|
} catch { 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) {
|
|
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
|
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 }
|
|
}
|
|
|
|
// ── 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 {
|
|
if (fs.existsSync(organizedPath))
|
|
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
|
} catch { 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(),
|
|
}
|
|
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)
|
|
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
|
|
|
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> {
|
|
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const worker = new Worker(resolveWorkerPath())
|
|
// 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: [] }
|
|
|
|
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 })
|
|
})
|
|
} |