feat: ajout de la prise en charges des codex

This commit is contained in:
MasutaYunikon committed 2026-05-06 00:22:07 +02:00
1 parent 7f6bbd6323
commit 83437def32
5 files changed
+101 -21

No files matched your search

+46 -7
View File
@@ -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<number, any>()
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<number>()
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
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<number>()
const organizedEpKeys = new Set<string>()
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'
}
+23 -8
View File
@@ -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)
+14 -2
View File
@@ -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
+7 -3
View File
@@ -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
+11 -1
View File
@@ -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 {