From 83437def32618c8a5b9ca4364db992762b9359f8 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Tue, 5 May 2026 23:38:53 +0200 Subject: [PATCH] feat: ajout de la prise en charges des codex --- server/lib/serie-helpers.ts | 53 ++++++++++++++++++++---- server/organize-worker.ts | 31 ++++++++++---- server/organize.ts | 16 ++++++- server/routes/series.ts | 10 +++-- src/components/serie/SerieSeasonCard.vue | 12 +++++- 5 files changed, 101 insertions(+), 21 deletions(-) diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index 9d1fef4..9b103e0 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -43,6 +43,34 @@ export function extractTorrentsFromSerieData(sd: any): any[] { return result } +/** + * Dédoublonne une liste d'épisodes par episode_number. + * Quand plusieurs variantes existent (ex. x264 + x265), on garde la première + * comme épisode principal et on fusionne les torrents des autres dans sa liste. + */ +export function deduplicateEpisodes(episodes: any[]): any[] { + const map = new Map() + for (const ep of episodes) { + const key = ep.episode_number + if (!map.has(key)) { + map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] }) + } else { + const primary = map.get(key)! + const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean)) + for (const t of ep.torrents ?? []) { + if (!t.infohash || !seenHashes.has(t.infohash)) { + primary.torrents.push(t) + if (t.infohash) seenHashes.add(t.infohash) + } + } + primary.torrent = primary.torrents[0] ?? null + primary.available = primary.available || ep.available + primary.organized = primary.organized || ep.organized + } + } + return Array.from(map.values()) +} + export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] { const resolved: any[] = [] const h = hash.toLowerCase() @@ -69,14 +97,19 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading' - const allEpisodeIds = new Set() + // Dédoublonnage : épisodes uniques par (season_number, episode_number) + // pour éviter de compter x264 + x265 comme deux épisodes distincts + const uniqueEpKeys = new Set() + const epIdToKey = new Map() for (const season of serieData.seasons ?? []) { for (const ep of season.episodes ?? []) { - allEpisodeIds.add(ep.id) + const key = `${season.season_number}:${ep.episode_number}` + uniqueEpKeys.add(key) + epIdToKey.set(ep.id, key) } } - const organizedEpisodeIds = new Set() + const organizedEpKeys = new Set() for (const t of allTorrents) { const hash = t.infohash?.toLowerCase() @@ -85,18 +118,24 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco if (Object.keys(orgFiles).length === 0) continue for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) { const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined - if (isOrganized) organizedEpisodeIds.add(ep.episode_id) + if (isOrganized) { + const key = epIdToKey.get(ep.episode_id) + if (key) organizedEpKeys.add(key) + } } } const manualOrg = organized['manual'] ?? {} for (const season of serieData.seasons ?? []) { for (const ep of season.episodes ?? []) { - if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id) + if (manualOrg[String(ep.id)]) { + const key = epIdToKey.get(ep.id) + if (key) organizedEpKeys.add(key) + } } } - if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none' - if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete' + if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none' + if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete' return 'partial' } diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 8d12f55..5d52a55 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -113,13 +113,15 @@ function buildFileMap( if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue for (const ep of season.episodes ?? []) { const paths: any[] = ep.paths ?? [] - let matchedPath: string | null = null + let matchedPath : string | null = null + let matchedPathObj: any = null for (const p of paths) { if (typeof p === 'string') { if (!matchedPath) matchedPath = p.replace(/\\/g, '/') } else if (p?.infohash?.toLowerCase() === h) { - matchedPath = p.path.replace(/\\/g, '/') + matchedPath = p.path.replace(/\\/g, '/') + matchedPathObj = p break } } @@ -127,8 +129,12 @@ function buildFileMap( if (!matchedPath) continue const filename = matchedPath.split('/').pop() ?? matchedPath - const fmtName = ep.formatted_name?.trim() - ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' + // 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' : null if (!nfoSupport && !fmtName) { @@ -289,14 +295,23 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser const ep = torrent._episode const season = torrent._season const paths: any[] = ep.paths ?? [] - let filePath: string | null = null + let filePath : string | null = null + let matchedPathObj: any = null for (const p of paths) { if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') } - else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break } + else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { + filePath = p.path.replace(/\\/g, '/') + matchedPathObj = p + break + } } const filename = filePath?.split('/').pop() ?? name - const fmtName = ep.formatted_name?.trim() - ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' + // 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' : null const srcExt = path.extname(filename) diff --git a/server/organize.ts b/server/organize.ts index 37a1db4..2798b4b 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -101,9 +101,14 @@ export async function scanMediaPath( 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() + idx(fmtBase, hash, fmtBase) + } } - // 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide + // 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) @@ -362,9 +367,16 @@ 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é + 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 expectedName: string = nfoSupport ? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName) - : (ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) + : (fmtName?.trim() ? fmtName.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) if (expectedName === currentName) continue diff --git a/server/routes/series.ts b/server/routes/series.ts index 2ecd870..a81f15a 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -10,6 +10,7 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, + deduplicateEpisodes, } from '../lib/serie-helpers.js' const router = Router() @@ -99,7 +100,7 @@ router.get('/series/:id', requireAuth, async (req, res) => { for (const t of (ep.torrents ?? [])) { if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = [] const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) - episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null }) + episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null }) availableEpisodeIds.add(ep.id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const isOrg = orgFiles[String(ep.id)] !== undefined @@ -124,7 +125,7 @@ router.get('/series/:id', requireAuth, async (req, res) => { if (!pack) continue seenPackHashes.add(hashKey) if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = [] - episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null }) + episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null }) availableEpisodeIds.add(ep.id) } } @@ -133,7 +134,7 @@ router.get('/series/:id', requireAuth, async (req, res) => { } const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { - const eps = season.episodes.map((ep: any) => { + const rawEps = season.episodes.map((ep: any) => { const epTorrents = episodeTorrentMap[ep.id] ?? [] const epTorrent = epTorrents[0] ?? null let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null @@ -160,6 +161,9 @@ router.get('/series/:id', requireAuth, async (req, res) => { organized: organizedEpisodeIds.has(ep.id), } }) + // Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number + // sont fusionnées en un seul épisode avec la liste de torrents combinée + const eps = deduplicateEpisodes(rawEps) const total = eps.filter((e: any) => e.available).length const orgCount = eps.filter((e: any) => e.organized).length const epTotal = eps.length diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index 8476ec8..1119335 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -491,7 +491,17 @@ function epExpectedName(ep: any): string { if (props.nfoSupport) { return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename } else { - return ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename + // Préférer le formatted_name du torrent qui correspond au fichier importé. + // On essaie de matcher par hash (si l'entrée organized l'expose), sinon on + // cherche le premier torrent qui a un formatted_name. + const usedHash = entry.hash?.toLowerCase() ?? null + const matchedName = usedHash + ? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name + : null + const name = matchedName + ?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name + ?? ep.formatted_name + return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename } } function epNeedsRename(ep: any): boolean {