diff --git a/server/index.ts b/server/index.ts index dec5ebd..356350d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,10 +18,9 @@ registerDriver(qbittorrentDriver) const app = express() const PORT = Number(process.env.PORT) || 9898 -const FANKAI_API = 'https://metadata.fankai.fr' -const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json') -const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL - ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json' +const FANKAI_API = 'https://metadata.fankai.fr' +const GITHUB_BASE = process.env.GITHUB_BASE + ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main' app.use(express.json()) app.use(cookieParser()) @@ -87,80 +86,257 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) res.json(await driver.healthcheck(client.config)) }) -// ── Helpers ──────────────────────────────────────────────────── -function readTorrents(): any[] { +// ── Cache GitHub ─────────────────────────────────────────────── + +const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures + +interface CacheEntry { data: T; expiresAt: number } +const _cache = new Map>() + +function cacheGet(key: string): T | null { + const entry = _cache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { _cache.delete(key); return null } + return entry.data as T +} + +function cacheSet(key: string, data: T): void { + _cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }) +} + +function cacheClear(): void { _cache.clear() } + +async function githubGet(urlPath: string): Promise { + const key = urlPath + const cached = cacheGet(key) + if (cached) return cached + const res = await fetch(`${GITHUB_BASE}/${urlPath}`) + if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`) + const data = await res.json() + cacheSet(key, data) + return data +} + +async function readAvailable(): Promise { try { - if (!fs.existsSync(TORRENTS_PATH)) return [] - return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) + return await githubGet('available.json') as number[] } catch { return [] } } + +async function readSerieData(serieId: number): Promise { + try { + return await githubGet(`series/${serieId}.json`) + } catch { return null } +} + +// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique +// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... } +function extractTorrentsFromSerieData(sd: any): any[] { + const result: any[] = [] + if (!sd) return result + + // Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison) + for (const t of sd.torrents ?? []) { + result.push({ + ...t, + raw : t.title, + type : 'pack_integrale', + serie_id : sd.id, + serie_title: sd.title, + // episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i] + _serieLevel: true, + _serieData : sd, + _torrentIdx: (sd.torrents ?? []).indexOf(t), + }) + } + + // Torrents au niveau saison → pack_saison + for (const season of sd.seasons ?? []) { + for (const t of season.torrents ?? []) { + result.push({ + ...t, + raw : t.title, + type : 'pack_saison', + serie_id : sd.id, + serie_title : sd.title, + season_id : season.id, + season_number: season.season_number, + _seasonLevel : true, + _season : season, + _torrentIdx : (season.torrents ?? []).indexOf(t), + }) + } + + // Torrents au niveau épisode → episode + for (const ep of season.episodes ?? []) { + for (const t of ep.torrents ?? []) { + result.push({ + ...t, + raw : t.title, + type : 'episode', + serie_id : sd.id, + serie_title : sd.title, + season_id : season.id, + season_number: season.season_number, + episode_id : ep.id, + episode_number: ep.episode_number, + _episodeLevel: true, + _episode : ep, + _torrentIdx : (ep.torrents ?? []).indexOf(t), + }) + } + } + } + + return result +} + +// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx] +// paths[i] = chemin du fichier dans le torrent i → on extrait le filename +function buildResolvedEpisodes(sd: any, torrentIdx: number, seasonFilter?: number): any[] { + const resolved: any[] = [] + for (const season of sd.seasons ?? []) { + if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue + for (const ep of season.episodes ?? []) { + const filePath = (ep.paths ?? [])[torrentIdx] + if (!filePath) continue + const filename = filePath.split('/').pop() ?? filePath + resolved.push({ + episode_id : ep.id, + episode_number: ep.episode_number, + season_number : season.season_number, + season_id : season.id, + filename, + original_filename: filename, // sera enrichi depuis l'API si besoin + }) + } + } + return resolved +} + async function fankaiGet(endpoint: string): Promise { const res = await fetch(`${FANKAI_API}${endpoint}`) if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`) return res.json() } + function normalizeTitle(s: string): string { return s.toLowerCase() .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') // strip accents (ï→i, é→e...) + .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]/g, ' ') .replace(/\s+/g, ' ') .trim() } -function indexBySerie(torrents: any[]): Map { - const map = new Map() - for (const t of torrents) { - if (!t.serie_id) continue - if (!map.has(t.serie_id)) map.set(t.serie_id, []) - map.get(t.serie_id)!.push(t) - } - return map +function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { + if (!url) return null + const stripped = url.replace(/^https?:\/\//, '') + return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}` } -function indexByNormalizedTitle(torrents: any[]): Map { - const map = new Map() - for (const t of torrents) { - const title = t.serie_title || t.show_title - if (!title) continue - const key = normalizeTitle(title) - if (!map.has(key)) map.set(key, []) - map.get(key)!.push(t) +function normalizeSerie(serie: any): any { + const poster = serie.poster_image ?? serie.images?.poster ?? null + const fanart = serie.fanart_image ?? serie.images?.fanart ?? null + return { + ...serie, + poster_image: imgProxy(poster, 300), + fanart_image: imgProxy(fanart, 1280, 60), } - return map } -// ── Series ───────────────────────────────────────────────────── +function normalizeSeason(season: any): any { + const poster = season.poster_image ?? season.images?.poster ?? null + return { + ...season, + poster_image: imgProxy(poster, 300), + } +} + +function normalizeEpisode(ep: any): any { + const thumb = ep.thumb_image ?? ep.thumbnail ?? null + return { + ...ep, + thumb_image: imgProxy(thumb, 400), + } +} + +// ── Enrichissement seriesData avec original_filename depuis l'API ────────────── +// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename +// dans chaque épisode du serieData, indexé par episode_id +async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise { + return Promise.all(seriesData.map(async (sd) => { + if (!sd) return sd + const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => { + let epsData: any[] = [] + try { + const res = await fankaiGet(`/seasons/${season.id}/episodes`) + epsData = Array.isArray(res) ? res : (res.episodes ?? []) + } catch { return season } + + // Index episode_id → original_filename + const origMap = new Map() + for (const ep of epsData) { + if (ep.id && ep.original_filename) origMap.set(ep.id, ep.original_filename) + } + + // Injecter dans les épisodes du serieData + const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({ + ...ep, + original_filename: origMap.get(ep.id) ?? null, + })) + + return { ...season, episodes: enrichedEpisodes } + })) + return { ...sd, seasons: enrichedSeasons } + })) +} + +// Helper réutilisable : charge + enrichit toutes les seriesData du cache +async function loadEnrichedSeriesData(): Promise { + const ids = await readAvailable() + const allSd = await Promise.all(ids.map(id => readSerieData(id))) + const valid = allSd.filter(Boolean) + return enrichSeriesDataWithOriginalFilenames(valid) +} function computeSerieDownloadState( - serieTorrents : any[], + serieData : any | null, organized : Record>, activeTorrents: Set ): 'none' | 'downloading' | 'partial' | 'complete' { - if (serieTorrents.length === 0) return 'none' + if (!serieData) return 'none' - const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase())) + const allTorrents = extractTorrentsFromSerieData(serieData) + if (allTorrents.length === 0) return 'none' + + const hasActive = allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase())) if (hasActive) return 'downloading' - // Collecter tous les épisodes uniques de la série (par episode_id) + // Collecter tous les episode_id de la série et ceux organisés const allEpisodeIds = new Set() const organizedEpisodeIds = new Set() - for (const t of serieTorrents) { - const hash = t.infohash?.toLowerCase() - const orgFiles = organized[hash] ?? {} - const files: any[] = t.torrent_files ?? [] - - for (const ep of t.resolved_episodes ?? []) { - allEpisodeIds.add(ep.episode_id) - // Cherche le fichier correspondant à cet épisode dans torrent_files - const epFile = files.find((f: any) => f.filename === ep.filename) - if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) { - organizedEpisodeIds.add(ep.episode_id) + for (const season of serieData.seasons ?? []) { + for (const ep of season.episodes ?? []) { + if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) { + allEpisodeIds.add(ep.id) } } } + // Vérifier via organized.json : si le filename du path est organisé + for (const t of allTorrents) { + const hash = t.infohash?.toLowerCase() + if (!hash) continue + const orgFiles = organized[hash] ?? {} + if (Object.keys(orgFiles).length === 0) continue + const resolved = buildResolvedEpisodes(serieData, t._torrentIdx, t.season_number) + for (const ep of resolved) { + if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) + } + } + if (allEpisodeIds.size === 0) return 'none' if (organizedEpisodeIds.size === 0) return 'none' if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete' @@ -169,9 +345,11 @@ function computeSerieDownloadState( app.get('/api/series', requireAuth, async (_req, res) => { try { - const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) - const byId = indexBySerie(torrents) - const byNormTitle = indexByNormalizedTitle(torrents) + const [apiData, availableIds] = await Promise.all([ + fankaiGet('/series'), + readAvailable(), + ]) + const availableSet = new Set(availableIds) // Charger organized.json let organized: Record> = {} @@ -187,39 +365,31 @@ app.get('/api/series', requireAuth, async (_req, res) => { const { category } = readSettings() const active = await dispatchList(category ?? 'fankai') for (const t of active) { - if (t.state === 'downloading' && t.hash) - activeTorrents.add(t.hash.toLowerCase()) + if (t.hash) activeTorrents.add(t.hash.toLowerCase()) } } catch {} - const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? []) + const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) - // Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente - const seenTitles = new Map() - for (const serie of seriesRaw) { - const key = normalizeTitle(serie.title ?? serie.show_title ?? '') - if (!seenTitles.has(key)) { - seenTitles.set(key, serie) - } else { - const existing = seenTitles.get(key)! - const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0 - const newHasTorrents = (byId.get(serie.id) ?? byNormTitle.get(key) ?? []).length > 0 - if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie) - else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie) - } - } - const seriesList = [...seenTitles.values()] + // Charger les series data en parallèle uniquement pour celles qui ont des torrents + const serieDataMap = new Map() + await Promise.all( + seriesRaw + .filter((s: any) => availableSet.has(s.id)) + .map(async (s: any) => { + const sd = await readSerieData(s.id) + if (sd) serieDataMap.set(s.id, sd) + }) + ) - res.json({ series: seriesList.map((serie: any) => { - // Fallback par titre normalisé si l'ID ne matche pas (Kaï→Kai migration) - const serieTorrents = byId.get(serie.id) - ?? byNormTitle.get(normalizeTitle(serie.title ?? serie.show_title ?? '')) - ?? [] + res.json({ series: seriesRaw.map((serie: any) => { + const hasTorrents = availableSet.has(serie.id) + const serieData = serieDataMap.get(serie.id) ?? null return { ...serie, - torrent_count : serieTorrents.length, - has_torrents : serieTorrents.length > 0, - download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents), + torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0, + has_torrents : hasTorrents, + download_state: computeSerieDownloadState(serieData, organized, activeTorrents), } })}) } catch (err) { @@ -230,98 +400,22 @@ app.get('/api/series', requireAuth, async (_req, res) => { app.get('/api/series/:id', requireAuth, async (req, res) => { const id = Number(req.params.id) try { - const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)]) + const [serieRaw, seasonsData, serieData] = await Promise.all([ + fankaiGet(`/series/${id}`), + fankaiGet(`/series/${id}/seasons`), + readSerieData(id), + ]) + const serie = normalizeSerie(serieRaw) const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) + + // Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.) const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => { - const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) - return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) } + const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) + const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) + return { ...normalizeSeason(season), episodes } })) - const allTorrents = readTorrents() - const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '') - const torrents = allTorrents.filter(t => - t.serie_id === id || - normalizeTitle(t.serie_title ?? t.show_title ?? '') === normApiTitle - ) - const allSeasonIds = new Set(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id)) - const availableEpisodeIds = new Set() - const availableBySnEn = new Set() // "sn:en" - const episodeTorrentMap: Record = {} - const episodeTorrentMapBySnEn: Record = {} - const seasonTorrentMap : Record = {} // keyed by season_id (scraper) - const seasonTorrentMapBySn: Record = {} // keyed by season_number (fallback) - const integraleTorrents: any[] = [] - const packEpisodesTorrents: any[] = [] - - for (const t of torrents) { - const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw, manual: t.manual ?? false } - for (const ep of t.resolved_episodes ?? []) { - availableEpisodeIds.add(ep.episode_id) - const snEnKey = `${ep.season_number}:${ep.episode_number}` - availableBySnEn.add(snEnKey) - if (t.type === 'episode') { - episodeTorrentMap[ep.episode_id] = ref - episodeTorrentMapBySnEn[snEnKey] = ref - } - } - if (t.type === 'pack_saison') { - const rs: any[] = t.resolved_seasons ?? [] - if (rs.length > 1) { - packEpisodesTorrents.push(t) - } else { - const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id] - for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref } - const sns = rs.length > 0 ? rs.map((r: any) => r.season_number) : [t.season_number] - for (const sn of sns) { if (sn != null) seasonTorrentMapBySn[sn] = ref } - } - } else if (t.type === 'pack_integrale') { - integraleTorrents.push(t) - } else if (t.type === 'pack_episodes') { - packEpisodesTorrents.push(t) - } - } - - // ── DEBUG ────────────────────────────────────────────── - console.log(`[debug /api/series/${id}] serie="${serie.title}" allSeasonIds=`, [...allSeasonIds]) - console.log(`[debug /api/series/${id}] seasonTorrentMap=`, seasonTorrentMap) - console.log(`[debug /api/series/${id}] seasons from API:`, seasonsWithEpisodes.map((s: any) => ({ id: s.id, season_number: s.season_number, title: s.title }))) - console.log(`[debug /api/series/${id}] torrents matched:`, torrents.map(t => ({ raw: t.raw, type: t.type, season_id: t.season_id, resolved_seasons: t.resolved_seasons }))) - // ── END DEBUG ────────────────────────────────────────── - - let promotedIntegrale: any = null - if (integraleTorrents.length === 0 && allSeasonIds.size > 0) { - for (const t of torrents.filter(t => t.type === 'pack_saison')) { - const rs: any[] = t.resolved_seasons ?? [] - const tIds = new Set(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : []) - if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break } - } - } - - if (integraleTorrents.length > 0 || promotedIntegrale) { - for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)] - if (promotedIntegrale) { - const idx = packEpisodesTorrents.indexOf(promotedIntegrale) - if (idx !== -1) packEpisodesTorrents.splice(idx, 1) - } - } - - function packEpisodesLabel(t: any): string { - const eps: any[] = t.resolved_episodes ?? [] - if (!eps.length) return t.raw - const bySeason = new Map() - for (const ep of eps) { - const sn = ep.season_number ?? 0 - if (!bySeason.has(sn)) bySeason.set(sn, []) - bySeason.get(sn)!.push(ep.episode_number) - } - return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => { - const s = nums.sort((a, b) => a - b) - const label = sn === 0 ? 'S0' : `S${sn}` - return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}` - }).join(' · ') - } - - // Charger organized.json pour les badges d'état + // Charger organized.json let organized: Record> = {} try { const orgPath = path.join(DATA_DIR, 'organized.json') @@ -329,53 +423,104 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) } catch {} - const organizedEpisodeIds = new Set() - const organizedBySnEn = new Set() - for (const t of torrents) { - const hash = t.infohash?.toLowerCase() - const files = t.torrent_files ?? [] - const orgFiles = organized[hash] ?? {} - if (Object.keys(orgFiles).length === 0) continue - for (const ep of t.resolved_episodes ?? []) { - const epFile = files.find((f: any) => - f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number - ) - if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) { - organizedEpisodeIds.add(ep.episode_id) - organizedBySnEn.add(`${ep.season_number}:${ep.episode_number}`) + // ── Construire les maps torrent depuis series/{id}.json ─────────── + const availableEpisodeIds = new Set() + const episodeTorrentMap : Record = {} // episode_id → ref + const seasonTorrentMapBySn : Record = {} // season_number → ref + const integraleTorrents : any[] = [] + const packEpisodesTorrents : any[] = [] + const organizedEpisodeIds = new Set() + + if (serieData) { + // ── Torrents niveau série (pack_integrale) ────────────────── + for (const [i, t] of (serieData.torrents ?? []).entries()) { + const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false } + integraleTorrents.push({ ...t, raw: t.title, _torrentIdx: i }) + + // Marquer les épisodes disponibles via paths[i] + const resolved = buildResolvedEpisodes(serieData, i) + for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) + + // Vérifier organisés + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + for (const ep of resolved) { + if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } } + + // ── Torrents niveau saison (pack_saison) ──────────────────── + for (const season of serieData.seasons ?? []) { + for (const [i, t] of (season.torrents ?? []).entries()) { + const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } + seasonTorrentMapBySn[season.season_number] = ref + + const resolved = buildResolvedEpisodes(serieData, i, season.season_number) + for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) + + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + for (const ep of resolved) { + if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) + } + } + + // ── Torrents niveau épisode ───────────────────────────── + for (const ep of season.episodes ?? []) { + for (const [i, t] of (ep.torrents ?? []).entries()) { + const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false } + episodeTorrentMap[ep.id] = ref + availableEpisodeIds.add(ep.id) + + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + const filename = (ep.paths ?? [])[i]?.split('/').pop() + if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) + } + } + } + + // Pack_saison multi-saisons → packEpisodesTorrents si couvre plusieurs saisons + // (cas où un seul torrent couvre plusieurs saisons mais pas toutes → affiché en hero) + // Pour l'instant on le traite comme pack_integrale si au niveau série } + // ── Promotion intégrale ─────────────────────────────────────────── + // Si pas de pack_integrale explicite mais un pack_saison couvre toutes les saisons + // → déjà géré car on met les torrents série en integraleTorrents directement + + // ── Enrichissement des saisons API ─────────────────────────────── const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { - const eps = season.episodes.map((ep: any) => ({ - ...ep, - available : availableEpisodeIds.has(ep.id) - || availableBySnEn.has(`${season.season_number}:${ep.episode_number}`), - torrent : episodeTorrentMap[ep.id] - ?? episodeTorrentMapBySnEn[`${season.season_number}:${ep.episode_number}`] - ?? null, - organized : organizedEpisodeIds.has(ep.id) - || organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`), - })) + const eps = season.episodes.map((ep: any) => { + // Chercher le torrent : niveau épisode d'abord, sinon le torrent de saison/intégrale sert juste de disponibilité + const epTorrent = episodeTorrentMap[ep.id] ?? null + // original_filename vient de l'API Fankai directement + return { + ...ep, + available : availableEpisodeIds.has(ep.id), + torrent : epTorrent, + organized : organizedEpisodeIds.has(ep.id), + } + }) const total = eps.filter((e: any) => e.available).length const orgCount = eps.filter((e: any) => e.organized).length const seasonOrganizedState = - orgCount === 0 ? 'none' : - orgCount >= total && total > 0 ? 'complete' : 'partial' + orgCount === 0 ? 'none' : + orgCount >= total && total > 0 ? 'complete' : 'partial' return { ...season, - torrent : seasonTorrentMap[season.id] ?? seasonTorrentMapBySn[season.season_number] ?? null, - organized_state : seasonOrganizedState, - organized_count : orgCount, - episodes : eps, + torrent : seasonTorrentMapBySn[season.season_number] ?? null, + organized_state : seasonOrganizedState, + organized_count : orgCount, + episodes : eps, } }) + // ── Hero bundles ────────────────────────────────────────────────── + function packEpisodesLabel(t: any): string { + return t.raw ?? t.title ?? 'Pack' + } + const heroBundles: any[] = [ - ...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })), - ...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []), - ...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })) + ...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })), + ...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) ] res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles }) @@ -405,19 +550,24 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) } catch {} - let torrentFinal: any[] = [] - try { - const tfPath = path.join(DATA_DIR, 'torrent_final.json') - if (fs.existsSync(tfPath)) - torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8')) - } catch {} - - const enriched = torrents.map((t: any) => { + const enriched = await Promise.all(torrents.map(async (t: any) => { const orgFiles = organized[t.hash] ?? {} - const meta = torrentFinal.find((m: any) => - m.infohash?.toLowerCase() === t.hash?.toLowerCase() - ) - const totalFiles = (meta?.torrent_files?.length ?? 0) || 1 + // Chercher les métadonnées via le cache GitHub + let totalFiles = 1 + try { + const availableIds = await readAvailable() + for (const id of availableIds) { + const sd = await readSerieData(id) + if (!sd) continue + const allT = extractTorrentsFromSerieData(sd) + const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) + if (match) { + const resolved = buildResolvedEpisodes(sd, match._torrentIdx, match.season_number) + totalFiles = resolved.length || 1 + break + } + } + } catch {} const doneFiles = Object.keys(orgFiles).length let organizeState: 'none' | 'partial' | 'done' = 'none' if (doneFiles >= totalFiles) organizeState = 'done' @@ -427,7 +577,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { const errorFiles = notif?.errorFiles ?? [] return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles } - }) + })) res.json(enriched) } catch (err) { @@ -469,7 +619,8 @@ app.post('/api/organize', requireAuth, async (req, res) => { return } try { - const result = await organizeTorrent(hash, name, save_path) + const seriesData = await loadEnrichedSeriesData() + const result = await organizeTorrent(hash, name, save_path, seriesData) res.json(result) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) @@ -513,27 +664,21 @@ app.get('/api/browse', requireAuth, (req, res) => { } }) -app.get('/api/torrents/status', requireAuth, (_req, res) => { +app.get('/api/torrents/status', requireAuth, async (_req, res) => { try { - if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return } - const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim() - if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return } - const data = JSON.parse(raw) - const count = Array.isArray(data) ? data.length : 0 - res.json({ exists: true, count, empty: count === 0 }) + const available = await readAvailable() + res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 }) } catch { res.json({ exists: false, count: 0, empty: true }) } }) -// ── Update torrent_final.json ────────────────────────────────── +// ── Update : vide le cache pour forcer le rechargement depuis GitHub ────────── app.post('/api/update', requireAuth, async (_req, res) => { try { - const response = await fetch(GITHUB_RAW_URL) - if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`) - const data = await response.json() as unknown[] - if (!Array.isArray(data)) throw new Error('Format invalide') - fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true }) - fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8') - res.json({ ok: true, count: data.length }) + cacheClear() + // Pré-charger available.json pour vérifier que GitHub répond + const availableIds = await readAvailable() + if (!Array.isArray(availableIds)) throw new Error('available.json invalide') + res.json({ ok: true, count: availableIds.length }) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } @@ -543,7 +688,8 @@ app.post('/api/scan', requireAuth, async (_req, res) => { try { const { mediaPath } = readSettings() const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') - const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH) + const seriesData = await loadEnrichedSeriesData() + const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) res.json({ ok: true, ...result }) } catch (err) { @@ -560,34 +706,23 @@ if (fs.existsSync(PUBLIC_PATH)) { // ── Start ────────────────────────────────────────────────────── app.listen(PORT, async () => { - // Télécharger torrent_final.json si absent - if (!fs.existsSync(TORRENTS_PATH)) { - try { - console.log('[fankarr] torrent_final.json absent → téléchargement...') - const response = await fetch(GITHUB_RAW_URL) - if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`) - const data = await response.json() as unknown[] - if (!Array.isArray(data)) throw new Error('Format invalide') - fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true }) - fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8') - console.log(`[fankarr] ${data.length} torrents téléchargés`) - } catch (err) { - console.warn(`[fankarr] ⚠ Téléchargement échoué: ${err instanceof Error ? err.message : err}`) - } + console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) + console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`) + + // Pré-chauffer le cache available.json au démarrage + try { + const available = await readAvailable() + console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`) + } catch (err) { + console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`) } - try { - const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) - const count = Array.isArray(data) ? data.length : 0 - if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide') - else console.log(`[fankarr] ${count} torrents chargés`) - } catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') } - console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) - - // Scan initial du mediaPath pour reconnaître les fichiers déjà présents + // Scan initial du mediaPath const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const { mediaPath } = readSettings() - scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err => + loadEnrichedSeriesData().then(seriesData => + scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) + ).catch(err => logger.error('organize', `scanMediaPath échoué: ${err}`) ) @@ -596,8 +731,10 @@ app.listen(PORT, async () => { const autoOrganize = async () => { try { const { category } = readSettings() + const seriesData = await loadEnrichedSeriesData() await autoOrganizeAll( () => dispatchList(category ?? 'fankai'), + seriesData, (result) => { if (result.done > 0 || result.errors > 0) { pushNotif({ ...result, at: new Date().toISOString() }) diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 3c89933..75ceaf2 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -4,8 +4,8 @@ * Worker thread dédié à l'organisation des fichiers. * Tourne dans un thread séparé pour ne pas bloquer le event loop Express. * - * Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] } - * Messages envoyés (parentPort.postMessage) : + * Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] } + * Messages envoyés : * { type: 'log', level: 'info'|'warn'|'error', msg: string } * { type: 'result', hash: string, name: string, done, skipped, errors } * { type: 'done' } @@ -17,7 +17,6 @@ import path from 'path' import { parentPort } from 'worker_threads' import { DATA_DIR } from './config.js' -const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json') const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') // ─── Utils log ──────────────────────────────────────────────── @@ -27,7 +26,6 @@ function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'err function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) } // ─── Settings ───────────────────────────────────────────────── - function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { try { const p = path.join(DATA_DIR, 'settings.json') @@ -37,7 +35,6 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode } // ─── Organized log ──────────────────────────────────────────── - function loadOrganized(): Record> { try { if (!fs.existsSync(ORGANIZED_PATH)) return {} @@ -56,18 +53,51 @@ function isOrganized(hash: string, filename: string): boolean { return !!loadOrganized()[hash]?.[filename] } -// ─── Torrent lookup ─────────────────────────────────────────── - -function findTorrentByHash(hash: string): any | null { - try { - if (!fs.existsSync(TORRENTS_PATH)) return null - const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) - return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null - } catch { return null } +// ─── Lookup torrent dans les seriesData passées via message ─── +function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null { + for (const sd of seriesData) { + // Niveau série + for (const t of sd.torrents ?? []) { + if (t.infohash?.toLowerCase() === hash.toLowerCase()) + return { torrent: t, serieData: sd } + } + // Niveau saison + for (const season of sd.seasons ?? []) { + for (const t of season.torrents ?? []) { + if (t.infohash?.toLowerCase() === hash.toLowerCase()) + return { torrent: { ...t, _season: season }, serieData: sd } + } + // Niveau épisode + for (const ep of season.episodes ?? []) { + for (const t of ep.torrents ?? []) { + if (t.infohash?.toLowerCase() === hash.toLowerCase()) + return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd } + } + } + } + } + return null } -// ─── Dossiers exclus (bonus, musique, images) ──────────────── +// Construit la map filename → { season_number, original_filename } +// depuis les paths[] de la série en utilisant l'index du torrent +function buildFileMap(serieData: any, torrentIdx: number, seasonFilter?: number): Map { + const map = new Map() + for (const season of serieData.seasons ?? []) { + if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue + for (const ep of season.episodes ?? []) { + const filePath = (ep.paths ?? [])[torrentIdx] + if (!filePath) continue + const filename = filePath.split('/').pop() ?? filePath + // original_filename vient de l'API Fankai (passé dans ep directement si disponible) + const original = ep.original_filename ?? filename + map.set(filename, { season_number: season.season_number, original_filename: original }) + } + } + return map +} +// ─── Dossiers exclus ───────────────────────────────────────── const EXCLUDED_FOLDERS = new Set([ 'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork', 'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced', @@ -85,26 +115,7 @@ function isInExcludedFolder(filePath: string[]): boolean { return false } -// ─── Map correction titres API → titres GitLab ─────────────── - -const SERIE_TITLE_GITLAB_MAP: Record = { - 'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', - 'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū', - 'Hikaru No Go Henshū' : 'Hikaru no Go Henshū', - 'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï', - 'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū', - 'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū', - 'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū', - 'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū', - 'Tower Of God Henshū' : 'Tower of God Henshū', -} - -function getGitlabSerieTitle(serieTitle: string): string { - return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle -} - // ─── GitLab NFO downloader ──────────────────────────────────── - const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository' const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' @@ -123,10 +134,8 @@ async function fetchBinary(url: string): Promise { } async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise { - const gitlabTitle = getGitlabSerieTitle(serieTitle) - const folderPath = `pack/${gitlabTitle}` - - log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`) + const folderPath = `pack/${serieTitle}` + log(`[nfo] Récupération dossier GitLab: ${serieTitle}`) let files: any[] = [] try { @@ -141,28 +150,22 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi page++ } } catch (err) { - warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`) + warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`) return } - if (files.length === 0) { - warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`) - return - } + if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return } const fileEntries = files.filter((f: any) => f.type === 'blob') log(`[nfo] ${fileEntries.length} fichiers à télécharger`) for (const entry of fileEntries) { - const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') + const relativePath = entry.path.replace(`pack/${serieTitle}/`, '') const destPath = path.join(destRoot, relativePath) - const destDir = path.dirname(destPath) - if (fs.existsSync(destPath)) continue - try { - fs.mkdirSync(destDir, { recursive: true }) - const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` + fs.mkdirSync(path.dirname(destPath), { recursive: true }) + const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(serieTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` const data = await fetchBinary(rawUrl) fs.writeFileSync(destPath, data) log(`[nfo] ✓ ${relativePath}`) @@ -170,15 +173,11 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`) } } - - log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`) + log(`[nfo] GitLab synchronisé pour "${serieTitle}"`) } // ─── Filesystem ops ─────────────────────────────────────────── - -function seasonFolder(n: number): string { - return `Saison ${n}` -} +function seasonFolder(n: number): string { return `Saison ${n}` } function tryHardlink(src: string, dest: string): boolean { try { fs.linkSync(src, dest); return true } @@ -189,67 +188,71 @@ function tryHardlink(src: string, dest: string): boolean { } // ─── Organise un torrent ────────────────────────────────────── - -async function organizeTorrent(hash: string, name: string, savePath: string) { +async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) { const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings() const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] } log(`[organize] ── ${name} ──`) log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`) - const torrent = findTorrentByHash(hash) - if (!torrent) { - error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`) + const found = findTorrentByHash(hash, seriesData) + if (!found) { + error(`[organize] ✗ Hash ${hash} introuvable dans les données série`) throw new Error(`Torrent introuvable : ${hash}`) } - log(`[organize] ✓ Trouvé : ${torrent.raw}`) - const serieTitle = torrent.serie_title || torrent.show_title || name - const torrentFiles: any[] = torrent.torrent_files ?? [] - log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`) + const { torrent, serieData } = found + const serieTitle = serieData.title ?? serieData.show_title ?? name + log(`[organize] ✓ Trouvé : ${torrent.title ?? name} — série: ${serieTitle}`) - // ── Téléchargement NFO/images depuis GitLab ─────────────── - if (nfoSupport) { - const serieTitlesMap = new Map() - serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle) - - const allTorrents: any[] = (() => { - try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] } - })() - const serieIdToTitle = new Map() - for (const t of allTorrents) { - if (t.serie_id && t.serie_title) serieIdToTitle.set(t.serie_id, t.serie_title) - } - - for (const ep of torrent.resolved_episodes ?? []) { - if (ep.serie_id && !serieTitlesMap.has(ep.serie_id)) { - const title = ep.serie_title ?? serieIdToTitle.get(ep.serie_id) - if (title) serieTitlesMap.set(ep.serie_id, title) - } - } - - for (const title of serieTitlesMap.values()) { - const serieDestRoot = path.join(mediaPath, title) - await downloadGitlabFolder(title, serieDestRoot) - } + // Déterminer l'index du torrent dans sa hiérarchie + let torrentIdx = 0 + let seasonFilter: number | undefined + if (torrent._episode) { + // Torrent niveau épisode : paths[0] est le seul fichier + torrentIdx = (torrent._episode.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) + seasonFilter = torrent._season?.season_number + } else if (torrent._season) { + torrentIdx = (torrent._season.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) + seasonFilter = torrent._season.season_number + } else { + torrentIdx = (serieData.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) } - // ── Cas fichier unique ──────────────────────────────────── - if (torrentFiles.length === 0) { - log(`[organize] torrent_files vide → tentative fichier unique`) - const candidates = [path.join(savePath, name), path.join(completePath, name)] - let src: string | null = null - for (const c of candidates) { - if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`) - else debug(`[organize] candidate absent: ${c}`) - if (fs.existsSync(c)) { src = c; break } - } - if (!src) throw new Error(`Source introuvable pour "${name}"`) + // Construire la map filename → { season_number, original_filename } + const fileMap = buildFileMap(serieData, torrentIdx, seasonFilter) - const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1 - const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum)) - const dest = path.join(destDir, name) - log(`[organize] dest: ${dest}`) + // ── NFO/images depuis GitLab ────────────────────────────── + if (nfoSupport) { + const serieDestRoot = path.join(mediaPath, serieTitle) + await downloadGitlabFolder(serieTitle, serieDestRoot) + } + + // ── Cas torrent épisode unique ──────────────────────────── + if (torrent._episode) { + const ep = torrent._episode + const season = torrent._season + const filePath = (ep.paths ?? [])[torrentIdx] + const filename = filePath?.split('/').pop() ?? name + const original = ep.original_filename ?? filename + const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) + const dest = path.join(destDir, original) + + if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 } + + const candidates = [ + path.join(savePath, name, filename), + path.join(savePath, filename), + path.join(completePath, name, filename), + path.join(completePath, filename), + ] + let src: string | null = null + for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } + + if (!src) { + error(`[organize] ✗ ${filename} introuvable`) + return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] } + } if (!fs.existsSync(dest)) { fs.mkdirSync(destDir, { recursive: true }) @@ -259,96 +262,42 @@ async function organizeTorrent(hash: string, name: string, savePath: string) { await fsp.copyFile(src, dest) await fsp.unlink(src) } - log(`[organize] ✓ ${organizeMode} OK`) - } else { - log(`[organize] → dest existe déjà, skip`) } - markOrganized(hash, name) + markOrganized(hash, filename) + log(`[organize] ✓ ${original} → Saison ${season.season_number}`) return { ...result, total: 1, done: 1 } } - // ── Map filename → season ───────────────────────────────── - const filenameSeasonMap = new Map() - - // Index par filename depuis resolved_episodes (évite les collisions episode_number) - const filenameToSeason = new Map() - for (const ep of torrent.resolved_episodes ?? []) { - if (ep.filename && ep.season_number !== undefined) - filenameToSeason.set(ep.filename, ep.season_number) + // ── Pack saison / intégrale : itérer sur les fichiers ───── + if (fileMap.size === 0) { + warn(`[organize] Aucun fichier mappé pour ${name}`) + return result } - for (const tf of torrentFiles) { - // Priorité 1 : resolved_episodes (saison API Fankai) - const fromResolved = filenameToSeason.get(tf.filename) - if (fromResolved !== undefined) { - filenameSeasonMap.set(tf.filename, fromResolved) - // Priorité 2 : season_number dans torrent_files (dossier Saison/Partie) - } else if (tf.season_number != null) { - filenameSeasonMap.set(tf.filename, Number(tf.season_number)) - // Priorité 3 : season_number du torrent - } else if (torrent.season_number !== undefined) { - filenameSeasonMap.set(tf.filename, torrent.season_number) - } - } - - // ── Map filename → original_filename (depuis l'API) ──────── - // Permet de renommer le fichier selon le nom standardisé Fankai - const filenameToOriginal = new Map() - for (const ep of torrent.resolved_episodes ?? []) { - if (ep.filename && ep.original_filename && ep.filename !== ep.original_filename) { - filenameToOriginal.set(ep.filename, ep.original_filename) - } - } - - result.total = torrentFiles.length - - for (const tf of torrentFiles) { - const filename = tf.filename - const filePath: string[] = tf.path - - // Skip les fichiers dans des dossiers bonus/musique/images - // Exception : fichiers avec season_number explicite (saison 0) - const tfMeta = torrentFiles.find((tf: any) => tf.filename === filename) - if (isInExcludedFolder(filePath) && tfMeta?.season_number == null) { - log(`[organize] skip bonus: ${filename}`) - result.skipped++ - continue - } + result.total = fileMap.size + for (const [filename, { season_number, original_filename }] of fileMap) { if (isOrganized(hash, filename)) { result.skipped++; continue } 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), ] - let src: string | null = null - for (const c of candidates) { - if (fs.existsSync(c)) { src = c; break } - } + for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } if (!src) { - const tried = candidates.map(c => ` • ${c}`).join('\n') - error(`[organize] ✗ ${filename} introuvable:\n${tried}`) + error(`[organize] ✗ ${filename} introuvable`) result.errors.push({ file: filename, error: 'Source introuvable' }) continue } - const seasonNum = filenameSeasonMap.get(filename) ?? 1 - const destFilename = filenameToOriginal.get(filename) ?? filename - const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum)) - const dest = path.join(destDir, destFilename) + const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number)) + const dest = path.join(destDir, original_filename) - if (fs.existsSync(dest)) { - markOrganized(hash, filename) - result.skipped++ - continue - } + if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue } try { fs.mkdirSync(destDir, { recursive: true }) @@ -360,7 +309,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) { } markOrganized(hash, filename) result.done++ - log(`[organize] ✓ ${destFilename} → Saison ${String(seasonNum).padStart(3,'0')}`) + log(`[organize] ✓ ${original_filename} → Saison ${season_number}`) } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' error(`[organize] ✗ ${filename}: ${msg}`) @@ -368,36 +317,39 @@ async function organizeTorrent(hash: string, name: string, savePath: string) { } } - log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`) + log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`) return result } // ─── Boucle principale du worker ───────────────────────────── - parentPort?.on('message', async (msg: any) => { if (msg.type !== 'run') return - const torrents: any[] = msg.torrents + const torrents: any[] = msg.torrents + const seriesData: any[] = msg.seriesData ?? [] const organized = loadOrganized() for (const t of torrents) { if (t.state !== 'seeding') continue - const torrent = findTorrentByHash(t.hash) - if (!torrent) { - warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`) + const found = findTorrentByHash(t.hash, seriesData) + if (!found) { + warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`) continue } - const files: any[] = torrent.torrent_files ?? [] - const allDone = files.length > 0 - ? files.every((f: any) => organized[t.hash]?.[f.filename]) + // Vérifier si déjà tout organisé + const { serieData } = found + const torrentIdx = (serieData.torrents ?? []).findIndex((st: any) => st.infohash?.toLowerCase() === t.hash.toLowerCase()) + const fileMap = buildFileMap(serieData, torrentIdx) + const allDone = fileMap.size > 0 + ? [...fileMap.keys()].every(f => organized[t.hash]?.[f]) : !!organized[t.hash]?.[t.name] if (allDone) continue try { - const result = await organizeTorrent(t.hash, t.name, t.save_path) + const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData) parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result }) } catch (err) { error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`) diff --git a/server/organize.ts b/server/organize.ts index d0111dd..ebab360 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -2,27 +2,23 @@ * organize.ts * =========== * Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express. - * Le worker gère tout le filesystem (copy, hardlink, move). */ import fs from 'fs' import path from 'path' import { Worker } from 'worker_threads' import { logger } from './logger.js' +import { DATA_DIR } from './config.js' + +const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') // Un seul worker à la fois let workerRunning = false // État précédent des torrents pour détecter les passages downloading → seeding -const _prevStates = new Map() // hash → state +const _prevStates = new Map() -// Résolution du chemin du worker -// En binaire Bun compilé : le worker est à côté de l'exécutable -// En Docker/dev : à côté du fichier courant function resolveWorkerPath(): string { - // Si Bun est présent → le worker est toujours à côté de l'exécutable - // (que ce soit un binaire compilé ou `bun run` en dev) - // En Node/tsx → on utilise import.meta.url if (typeof (globalThis as any).Bun !== 'undefined') { return path.join(path.dirname((process as any).execPath), 'organize-worker.js') } @@ -38,13 +34,12 @@ export interface OrganizeResult { /** * Scan initial du mediaPath au démarrage. - * Parcourt récursivement le dossier Jellyfin et marque dans organized.json - * tous les fichiers déjà présents qui correspondent à un torrent connu. + * Construit l'index filename → infohash depuis les seriesData passées en paramètre. */ export async function scanMediaPath( - mediaPath : string, - torrentsPath: string, - organizedPath: string + mediaPath : string, + organizedPath: string, + seriesData : any[] = [] ): Promise<{ found: number; added: number }> { const result = { found: 0, added: 0 } @@ -53,27 +48,45 @@ export async function scanMediaPath( return result } - // Charger torrent_final.json - let torrents: any[] = [] - try { - if (!fs.existsSync(torrentsPath)) return result - torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8')) - } catch { - logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json') - return result - } - - // Construire un index filename → { hash, infohash } + // Construire index filename → infohash depuis les seriesData const filenameIndex = new Map() // filename → infohash - for (const t of torrents) { - const hash = t.infohash?.toLowerCase() - if (!hash) continue - for (const f of t.torrent_files ?? []) { - if (f.filename) filenameIndex.set(f.filename, hash) + for (const sd of seriesData) { + // Torrents niveau série + for (const [i, t] of (sd.torrents ?? []).entries()) { + const hash = t.infohash?.toLowerCase() + if (!hash) continue + for (const season of sd.seasons ?? []) { + for (const ep of season.episodes ?? []) { + const filePath = (ep.paths ?? [])[i] + if (!filePath) continue + const filename = filePath.split('/').pop() + if (filename) filenameIndex.set(filename, hash) + } + } } - // Fichier unique (torrent_files vide) → on indexe par raw name - if ((t.torrent_files ?? []).length === 0 && t.raw) { - filenameIndex.set(t.raw, hash) + // Torrents niveau saison + for (const season of sd.seasons ?? []) { + for (const [i, t] of (season.torrents ?? []).entries()) { + const hash = t.infohash?.toLowerCase() + if (!hash) continue + for (const ep of season.episodes ?? []) { + const filePath = (ep.paths ?? [])[i] + if (!filePath) continue + const filename = filePath.split('/').pop() + if (filename) filenameIndex.set(filename, hash) + } + } + // Torrents niveau épisode + for (const ep of season.episodes ?? []) { + for (const [i, t] of (ep.torrents ?? []).entries()) { + const hash = t.infohash?.toLowerCase() + if (!hash) continue + const filePath = (ep.paths ?? [])[i] + if (!filePath) continue + const filename = filePath.split('/').pop() + if (filename) filenameIndex.set(filename, hash) + } + } } } @@ -84,10 +97,8 @@ export async function scanMediaPath( organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) } catch {} - // Construire le set des fichiers présents sur le disque (pour nettoyage) - const presentFiles = new Set() // "hash:filename" + const presentFiles = new Set() - // Parcourir récursivement mediaPath function walk(dir: string) { let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } @@ -101,8 +112,7 @@ export async function scanMediaPath( const hash = filenameIndex.get(entry.name) if (!hash) continue presentFiles.add(`${hash}:${entry.name}`) - if (organized[hash]?.[entry.name]) continue // déjà connu - // Marquer comme organisé + if (organized[hash]?.[entry.name]) continue if (!organized[hash]) organized[hash] = {} organized[hash][entry.name] = new Date().toISOString() result.added++ @@ -112,28 +122,21 @@ export async function scanMediaPath( walk(mediaPath) - // Nettoyer les entrées orphelines (fichiers supprimés) + // Nettoyer les entrées orphelines let removed = 0 for (const [hash, files] of Object.entries(organized)) { for (const filename of Object.keys(files)) { - if (!presentFiles.has(`${hash}:${filename}`)) { - // Vérifier que ce hash est bien dans le filenameIndex - // (évite de supprimer des entrées de torrents non-scannés) - if (filenameIndex.get(filename) === hash) { - delete organized[hash][filename] - removed++ - } + if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) { + delete organized[hash][filename] + removed++ } } - // Supprimer l'entrée du torrent si elle est vide - if (Object.keys(organized[hash]).length === 0) { - delete organized[hash] - } + if (Object.keys(organized[hash]).length === 0) delete organized[hash] } if (result.added > 0 || removed > 0) { fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') - logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés, ${removed} entrées orphelines supprimées`) + logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`) } else { logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`) } @@ -142,13 +145,12 @@ export async function scanMediaPath( } /** - * Lance l'auto-organisation dans un worker thread. - * listFn est appelée dans le thread principal (accès qBittorrent), - * puis les données sont passées au worker. + * Auto-organisation — appelée depuis index.ts avec les seriesData du cache GitHub. */ export async function autoOrganizeAll( - listFn : () => Promise, - onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void + listFn : () => Promise, + seriesData : any[], + onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void ): Promise { if (workerRunning) { console.log('[organize] Worker déjà en cours, skip') @@ -165,19 +167,16 @@ export async function autoOrganizeAll( if (torrents.length === 0) return - const isFirstRun = _prevStates.size === 0 + const isFirstRun = _prevStates.size === 0 const seedingCount = torrents.filter(t => t.state === 'seeding').length - // Détecter les torrents qui viennent de passer en seeding (downloading → seeding) const newlySeeding = torrents.filter(t => { const prev = _prevStates.get(t.hash) return t.state === 'seeding' && (prev === undefined || prev !== 'seeding') }) - // Mettre à jour l'état précédent for (const t of torrents) _prevStates.set(t.hash, t.state) - // Lancer seulement si : premier run avec des seeders, ou nouveau torrent terminé if (!isFirstRun && newlySeeding.length === 0) return if (seedingCount === 0) return @@ -188,9 +187,7 @@ export async function autoOrganizeAll( } workerRunning = true - const workerPath = resolveWorkerPath() - - const worker = new Worker(workerPath) + const worker = new Worker(resolveWorkerPath()) worker.on('message', (msg: any) => { if (msg.type === 'log') { @@ -200,12 +197,12 @@ export async function autoOrganizeAll( } else if (msg.type === 'result') { console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`) onResult?.({ - hash : msg.hash, - name : msg.name, - done : msg.done, - skipped : msg.skipped, - errors : msg.errors.length, - errorFiles : msg.errors, + hash : msg.hash, + name : msg.name, + done : msg.done, + skipped : msg.skipped, + errors : msg.errors.length, + errorFiles: msg.errors, }) } else if (msg.type === 'done') { console.log('[organize] Worker terminé') @@ -214,34 +211,23 @@ export async function autoOrganizeAll( } }) - worker.on('error', (err) => { - console.error('[organize] Worker erreur:', err) - workerRunning = false - }) + worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false }) + worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false }) - worker.on('exit', (code) => { - if (code !== 0) console.error(`[organize] Worker exit code ${code}`) - workerRunning = false - }) - - // Envoyer les torrents au worker - worker.postMessage({ type: 'run', torrents }) + worker.postMessage({ type: 'run', torrents, seriesData }) } /** - * Organisation manuelle d'un torrent (appelée depuis l'API /organize). - * Lance aussi dans un worker thread. + * Organisation manuelle d'un torrent. */ export async function organizeTorrent( - hash : string, - name : string, - savePath: string + hash : string, + name : string, + savePath : string, + seriesData: any[] ): Promise { return new Promise((resolve, reject) => { - const workerPath = resolveWorkerPath() - const worker = new Worker(workerPath) - - // On envoie un seul torrent "fictif seeding" + const worker = new Worker(resolveWorkerPath()) const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' } const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] } @@ -264,6 +250,6 @@ export async function organizeTorrent( worker.on('error', (err) => { worker.terminate(); reject(err) }) worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) }) - worker.postMessage({ type: 'run', torrents: [fakeTorrent] }) + worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData }) }) } \ No newline at end of file