|
|
|
@@ -68,7 +68,8 @@ function tryHardlink(src: string, dest: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
fs.linkSync(src, dest)
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -90,35 +91,40 @@ export interface OrganizeResult {
|
|
|
|
|
|
|
|
|
|
export async function organizeTorrent(
|
|
|
|
|
hash : string,
|
|
|
|
|
name : string, // nom du dossier torrent dans completePath
|
|
|
|
|
savePath: string // save_path retourné par qBittorrent
|
|
|
|
|
name : string, // nom du dossier torrent (retourné par qB)
|
|
|
|
|
savePath: string // save_path retourné par qBittorrent
|
|
|
|
|
): Promise<OrganizeResult> {
|
|
|
|
|
const settings = readSettings()
|
|
|
|
|
const { mediaPath, completePath, organizeMode } = settings
|
|
|
|
|
|
|
|
|
|
console.log(`[organize] ── Début organisation ──────────────────────`)
|
|
|
|
|
console.log(`[organize] hash : ${hash}`)
|
|
|
|
|
console.log(`[organize] name : ${name}`)
|
|
|
|
|
console.log(`[organize] save_path : ${savePath}`)
|
|
|
|
|
console.log(`[organize] mediaPath : ${mediaPath}`)
|
|
|
|
|
console.log(`[organize] completePath: ${completePath}`)
|
|
|
|
|
console.log(`[organize] mode : ${organizeMode}`)
|
|
|
|
|
|
|
|
|
|
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
|
|
|
|
|
|
|
|
|
// Trouver le torrent dans torrent_final.json via infohash
|
|
|
|
|
const torrent = findTorrentByHash(hash)
|
|
|
|
|
if (!torrent) {
|
|
|
|
|
throw new Error(`Torrent introuvable dans torrent_final.json pour le hash : "${hash}"`)
|
|
|
|
|
console.error(`[organize] ✗ Aucun torrent avec infohash="${hash}" dans torrent_final.json`)
|
|
|
|
|
throw new Error(`Torrent introuvable pour le hash : "${hash}"`)
|
|
|
|
|
}
|
|
|
|
|
console.log(`[organize] ✓ Torrent trouvé : ${torrent.raw}`)
|
|
|
|
|
|
|
|
|
|
const serieTitle = torrent.serie_title || torrent.show_title || name
|
|
|
|
|
const serieTitle = torrent.serie_title || torrent.show_title || name
|
|
|
|
|
const torrentFiles: any[] = torrent.torrent_files ?? []
|
|
|
|
|
console.log(`[organize] serie_title : ${serieTitle}`)
|
|
|
|
|
console.log(`[organize] fichiers : ${torrentFiles.length}`)
|
|
|
|
|
|
|
|
|
|
if (torrentFiles.length === 0) {
|
|
|
|
|
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Résoudre season_number par episode_id
|
|
|
|
|
const epSeasonMap = new Map<number, number>()
|
|
|
|
|
for (const ep of torrent.resolved_episodes ?? []) {
|
|
|
|
|
if (ep.filename) {
|
|
|
|
|
epSeasonMap.set(ep.episode_number, ep.season_number)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Map filename → season_number
|
|
|
|
|
// Map filename → season_number depuis resolved_episodes
|
|
|
|
|
const filenameSeasonMap = new Map<string, number>()
|
|
|
|
|
for (const ep of torrent.resolved_episodes ?? []) {
|
|
|
|
|
if (ep.filename && ep.season_number !== undefined) {
|
|
|
|
@@ -130,51 +136,59 @@ export async function organizeTorrent(
|
|
|
|
|
|
|
|
|
|
for (const tf of torrentFiles) {
|
|
|
|
|
const filename = tf.filename
|
|
|
|
|
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
|
|
|
|
|
const filePath: string[] = tf.path
|
|
|
|
|
|
|
|
|
|
console.log(`[organize] ── Fichier : ${filename}`)
|
|
|
|
|
console.log(`[organize] path[] : ${JSON.stringify(filePath)}`)
|
|
|
|
|
|
|
|
|
|
// Skip si déjà organisé
|
|
|
|
|
if (isOrganized(hash, filename)) {
|
|
|
|
|
console.log(`[organize] → déjà organisé, skip`)
|
|
|
|
|
result.skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Construire le chemin source
|
|
|
|
|
// save_path peut déjà contenir le nom du dossier ou pas selon le client
|
|
|
|
|
const torrentDir = path.join(savePath, name)
|
|
|
|
|
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
|
|
|
|
|
// Essayer plusieurs chemins sources possibles
|
|
|
|
|
const candidates = [
|
|
|
|
|
path.join(savePath, name, ...filePath.slice(0, -1), filename),
|
|
|
|
|
path.join(savePath, ...filePath.slice(0, -1), filename),
|
|
|
|
|
path.join(savePath, name, filename),
|
|
|
|
|
path.join(savePath, filename),
|
|
|
|
|
path.join(completePath, name, ...filePath.slice(0, -1), filename),
|
|
|
|
|
path.join(completePath, name, filename),
|
|
|
|
|
path.join(completePath, filename),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// Chemin source = dossier torrent + sous-dossiers + fichier
|
|
|
|
|
const subPath = filePath.length > 1
|
|
|
|
|
? path.join(...filePath.slice(0, -1), filename)
|
|
|
|
|
: filename
|
|
|
|
|
let src: string | null = null
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
console.log(`[organize] candidate: ${candidate} → ${fs.existsSync(candidate) ? '✓ EXISTS' : '✗ absent'}`)
|
|
|
|
|
if (fs.existsSync(candidate)) {
|
|
|
|
|
src = candidate
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let src = path.join(torrentDir, subPath)
|
|
|
|
|
if (!fs.existsSync(src)) {
|
|
|
|
|
// Fallback: save_path sans le nom du torrent
|
|
|
|
|
src = path.join(torrentDirAlt, subPath)
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(src)) {
|
|
|
|
|
// Fallback: fichier directement dans save_path/name
|
|
|
|
|
src = path.join(torrentDir, filename)
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(src)) {
|
|
|
|
|
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
|
|
|
|
|
if (!src) {
|
|
|
|
|
const err = `Source introuvable (essayé ${candidates.length} chemins)`
|
|
|
|
|
console.error(`[organize] ✗ ${err}`)
|
|
|
|
|
result.errors.push({ file: filename, error: err })
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Déterminer la saison
|
|
|
|
|
let seasonNum = filenameSeasonMap.get(filename)
|
|
|
|
|
if (seasonNum === undefined) {
|
|
|
|
|
// Fallback : season_number = 1
|
|
|
|
|
seasonNum = 1
|
|
|
|
|
}
|
|
|
|
|
console.log(`[organize] ✓ source : ${src}`)
|
|
|
|
|
|
|
|
|
|
// Construire le chemin destination
|
|
|
|
|
// Déterminer la saison
|
|
|
|
|
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
|
|
|
|
console.log(`[organize] saison : ${seasonNum}`)
|
|
|
|
|
|
|
|
|
|
// Chemin destination
|
|
|
|
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
|
|
|
|
const dest = path.join(destDir, filename)
|
|
|
|
|
console.log(`[organize] dest : ${dest}`)
|
|
|
|
|
|
|
|
|
|
// Skip si dest existe déjà
|
|
|
|
|
if (fs.existsSync(dest)) {
|
|
|
|
|
console.log(`[organize] → dest existe déjà, skip`)
|
|
|
|
|
markOrganized(hash, filename)
|
|
|
|
|
result.skipped++
|
|
|
|
|
continue
|
|
|
|
@@ -186,33 +200,29 @@ export async function organizeTorrent(
|
|
|
|
|
if (organizeMode === 'hardlink') {
|
|
|
|
|
const ok = tryHardlink(src, dest)
|
|
|
|
|
if (!ok) {
|
|
|
|
|
// Hardlink impossible (filesystems différents) → copy
|
|
|
|
|
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
|
|
|
|
|
console.warn(`[organize] hardlink impossible → fallback copy`)
|
|
|
|
|
copyFile(src, dest)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// move = copy + delete source
|
|
|
|
|
copyFile(src, dest)
|
|
|
|
|
fs.unlinkSync(src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markOrganized(hash, filename)
|
|
|
|
|
result.done++
|
|
|
|
|
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
|
|
|
|
|
console.log(`[organize] ✓ ${organizeMode} OK`)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
result.errors.push({
|
|
|
|
|
file : filename,
|
|
|
|
|
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
|
|
|
|
})
|
|
|
|
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
|
|
|
|
console.error(`[organize] ✗ ${msg}`)
|
|
|
|
|
result.errors.push({ file: filename, error: msg })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[organize] ── Résultat : ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auto-organise polling ────────────────────────────────────
|
|
|
|
|
// Appelé depuis index.ts au démarrage, toutes les 30s
|
|
|
|
|
// Détecte les torrents "seeding" non encore organisés
|
|
|
|
|
|
|
|
|
|
export async function autoOrganizeAll(
|
|
|
|
|
listFn: () => Promise<any[]>
|
|
|
|
@@ -220,22 +230,35 @@ export async function autoOrganizeAll(
|
|
|
|
|
let torrents: any[]
|
|
|
|
|
try {
|
|
|
|
|
torrents = await listFn()
|
|
|
|
|
} catch { return }
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[organize] autoOrganizeAll: impossible de récupérer la liste des torrents:', err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[organize] autoOrganizeAll: ${torrents.length} torrents récupérés`)
|
|
|
|
|
|
|
|
|
|
const organized = loadOrganized()
|
|
|
|
|
|
|
|
|
|
for (const t of torrents) {
|
|
|
|
|
if (t.state !== 'seeding') continue
|
|
|
|
|
if (t.state !== 'seeding') {
|
|
|
|
|
console.log(`[organize] skip ${t.name} (state=${t.state})`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Vérifier si tous les fichiers sont déjà organisés
|
|
|
|
|
const torrent = findTorrentByHash(t.hash)
|
|
|
|
|
if (!torrent) continue
|
|
|
|
|
if (!torrent) {
|
|
|
|
|
console.warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans torrent_final.json`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const files: any[] = torrent.torrent_files ?? []
|
|
|
|
|
if (files.length === 0) continue
|
|
|
|
|
|
|
|
|
|
const allDone = files.every(f => organized[t.hash]?.[f.filename])
|
|
|
|
|
if (allDone) continue
|
|
|
|
|
const allDone = files.every((f: any) => organized[t.hash]?.[f.filename])
|
|
|
|
|
if (allDone) {
|
|
|
|
|
console.log(`[organize] ${t.name} → déjà entièrement organisé`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[organize] Auto-organisation : ${t.name}`)
|
|
|
|
|
try {
|
|
|
|
|