fix: utilisation de formated_name depuis les paths

feat: ajout de la mise a jour des id's
This commit is contained in:
MasutaYunikon committed 2026-05-06 16:41:53 +02:00
1 parent 83437def32
commit 39463bfbcb
6 files changed
+185 -40

No files matched your search

+11 -4
View File
@@ -11,7 +11,7 @@ import transmissionDriver from './torrent-clients/transmission.js'
import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges } from './organize.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
@@ -97,7 +97,10 @@ server.listen(PORT, async () => {
} catch {}
loadEnrichedSeriesData()
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
.then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
const autoOrganize = async () => {
@@ -144,8 +147,12 @@ server.listen(PORT, async () => {
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
+24
View File
@@ -43,6 +43,30 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
original_filename: string | null
} {
const h = hash?.toLowerCase() ?? null
const pathEntry = h
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
: null
return {
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
}
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
+16 -16
View File
@@ -129,12 +129,12 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
// Préférer le formatted_name du path (spécifique à ce torrent) sur celui de l'épisode
const rawFmtName = matchedPathObj?.formatted_name?.trim()
? matchedPathObj.formatted_name
: ep.formatted_name
const fmtName = rawFmtName?.trim()
? rawFmtName.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
if (!nfoSupport && !fmtName) {
@@ -143,8 +143,8 @@ function buildFileMap(
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
@@ -306,18 +306,18 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
}
}
const filename = filePath?.split('/').pop() ?? name
// Préférer le formatted_name du path (spécifique à ce torrent) sur celui de l'épisode
const rawFmtName = matchedPathObj?.formatted_name?.trim()
? matchedPathObj.formatted_name
: ep.formatted_name
const fmtName = rawFmtName?.trim()
? rawFmtName.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
+105 -10
View File
@@ -100,10 +100,12 @@ export async function scanMediaPath(
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
// formatted_name spécifique à ce torrent (priorité sur celui de l'épisode)
if (p.formatted_name?.trim()) {
const fmtBase = p.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
// 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)
}
}
@@ -332,6 +334,100 @@ export async function autoOrganizeAll(
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)]) {
logger.warn('organize', `Migration ID : conflit ep ${oldId} → ${newId} — entrée existante conservée`)
newEpisodes[oldIdStr] = entry
} 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(
@@ -367,16 +463,15 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Préférer le formatted_name du path correspondant au hash organisé
// 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 fmtName = matchedPathEntry?.formatted_name?.trim()
? matchedPathEntry.formatted_name
: ep.formatted_name
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
const expectedName: string = nfoSupport
? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (fmtName?.trim() ? fmtName.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
if (expectedName === currentName) continue
+14 -9
View File
@@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming } from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
@@ -51,10 +52,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
? (rawNfo ?? rawOrig ?? srcFilename)
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
const resolvedExt = path.extname(resolvedName)
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
@@ -186,11 +188,12 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, entryHash)
let newName: string
if (nfoSupport) {
newName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
newName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
newName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
newName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const oldPath = orgEntry.dest_path
@@ -338,11 +341,12 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
if (!orgEntry) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
const needsRename = expectedName !== currentName
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
@@ -380,11 +384,12 @@ router.post('/rename-all', requireAuth, async (req, res) => {
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (expectedName === currentName) continue
const oldPath = orgEntry.dest_path
+15 -1
View File
@@ -1,12 +1,26 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent } from '../organize.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})