import express from 'express' import cookieParser from 'cookie-parser' import fs from 'fs' import path from 'path' import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js' import { readSettings, writeSettings } from './settings.js' import { registerDriver, getDriver, getAvailableClients, listClients, addClient, removeClient, getClient, sanitizeClient, dispatchDownload, dispatchList } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' registerDriver(qbittorrentDriver) const app = express() const PORT = Number(process.env.PORT) || 9898 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()) const PUBLIC_PATH = path.join(BASE_DIR, 'public') if (fs.existsSync(PUBLIC_PATH)) { app.use(express.static(PUBLIC_PATH)) } // ── Auth ─────────────────────────────────────────────────────── app.get('/api/auth/status', authStatus) app.post('/api/auth/setup', authSetup) app.post('/api/auth/login', authLogin) app.post('/api/auth/logout', authLogout) // ── Settings ─────────────────────────────────────────────────── app.get('/api/settings', requireAuth, (_req, res) => { res.json(readSettings()) }) app.post('/api/settings', requireAuth, (req, res) => { const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles } = req.body const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles }) logger.info('api', 'Paramètres mis à jour') res.json(updated) }) // ── Torrent clients ──────────────────────────────────────────── app.get('/api/torrent-clients/available', requireAuth, (_req, res) => { res.json(getAvailableClients()) }) app.get('/api/torrent-clients', requireAuth, (_req, res) => { res.json(listClients().map((c: any) => sanitizeClient(c))) }) app.post('/api/torrent-clients', requireAuth, (req, res) => { const { name, type, config } = req.body if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return } if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } res.json(sanitizeClient(addClient(name, type, config))) }) app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => { const ok = removeClient(String(req.params.uuid)) if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return } res.json({ ok: true }) }) app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => { const { type, config } = req.body if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return } const driver = getDriver(type) if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } res.json(await driver.test(config)) }) app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => { const client = getClient(String(req.params.uuid)) if (!client) { res.status(404).json({ error: 'Client introuvable' }); return } const driver = getDriver(client.type) if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return } res.json(await driver.test(client.config)) }) app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => { const client = getClient(String(req.params.uuid)) if (!client) { res.status(404).json({ error: 'Client introuvable' }); return } const driver = getDriver(client.type) if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return } res.json(await driver.healthcheck(client.config)) }) // ── Cache GitHub ─────────────────────────────────────────────── const CACHE_TTL_MS = 6 * 60 * 60 * 1000 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 cached = cacheGet(urlPath) 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(urlPath, data) return data } async function readAvailable(): Promise { try { 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 } } function extractTorrentsFromSerieData(sd: any): any[] { const result: any[] = [] if (!sd) return result for (const t of sd.torrents ?? []) { result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) }) } 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) }) } 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 } function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] { const resolved: any[] = [] const h = hash.toLowerCase() for (const season of sd.seasons ?? []) { if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue for (const ep of season.episodes ?? []) { const paths: any[] = ep.paths ?? [] let filePath: string | null = null for (const p of paths) { if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') } else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break } } 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 }) } } 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 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 normalizeSerie(serie: any): any { return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) } } function normalizeSeason(season: any): any { return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) } } function normalizeEpisode(ep: any): any { return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) } } async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise { return Promise.all(seriesData.map(async (sd) => { if (!sd) return sd const [serieApi, enrichedSeasons] = await Promise.all([ fankaiGet(`/series/${sd.id}`).catch(() => null), Promise.all((sd.seasons ?? []).map(async (season: any) => { let epsData: any[] = [] try { let lastErr: any for (let attempt = 0; attempt < 2; attempt++) { try { const res = await fankaiGet(`/seasons/${season.id}/episodes`); epsData = Array.isArray(res) ? res : (res.episodes ?? []); break } catch (e) { lastErr = e; if (attempt === 0) await new Promise(r => setTimeout(r, 500)) } } if (epsData.length === 0 && lastErr) throw lastErr } catch { return season } const origMap = new Map() for (const ep of epsData) { if (ep.id) origMap.set(ep.id, { original_filename: ep.original_filename ?? '', formatted_name: ep.formatted_name ?? '' }) } return { ...season, episodes: (season.episodes ?? []).map((ep: any) => ({ ...ep, original_filename: origMap.get(ep.id)?.original_filename ?? null, formatted_name: origMap.get(ep.id)?.formatted_name ?? null })) } })) ]) return { ...sd, title_for_plex: serieApi?.title_for_plex ?? null, seasons: enrichedSeasons } })) } async function loadEnrichedSeriesData(): Promise { const ids = await readAvailable() const allSd = await Promise.all(ids.map(id => readSerieData(id))) return enrichSeriesDataWithOriginalFilenames(allSd.filter(Boolean)) } function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' { if (!serieData) return 'none' const allTorrents = extractTorrentsFromSerieData(serieData) if (allTorrents.length === 0) return 'none' if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading' const allEpisodeIds = new Set() const organizedEpisodeIds = new Set() 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) } } for (const t of allTorrents) { const hash = t.infohash?.toLowerCase() if (!hash) continue const orgFiles = organized[hash] ?? {} if (Object.keys(orgFiles).length === 0) continue for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } } if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none' if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete' return 'partial' } // ── Routes série ─────────────────────────────────────────────── app.get('/api/series', requireAuth, async (_req, res) => { try { const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()]) const availableSet = new Set(availableIds) let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const activeTorrents = new Set() try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {} const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) 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: seriesRaw.map((serie: any) => { const hasTorrents = availableSet.has(serie.id) const serieData = serieDataMap.get(serie.id) ?? null return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } })}) } catch (err) { logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) app.get('/api/series/:id', requireAuth, async (req, res) => { const id = Number(req.params.id) try { 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 ?? []) const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => { const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) return { ...normalizeSeason(season), episodes } })) let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const availableEpisodeIds = new Set() const episodeTorrentMap : Record = {} const seasonTorrentMapBySn : Record = {} const integraleTorrents : any[] = [] const packEpisodesTorrents : any[] = [] const organizedEpisodeIds = new Set() if (serieData) { for (const t of (serieData.torrents ?? [])) { integraleTorrents.push({ ...t, raw: t.title }) const resolved = buildResolvedEpisodes(serieData, t.infohash) 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) } } for (const season of serieData.seasons ?? []) { for (const t of (season.torrents ?? [])) { seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } const resolved = buildResolvedEpisodes(serieData, t.infohash, 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) } } for (const ep of season.episodes ?? []) { for (const t of (ep.torrents ?? [])) { episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true } availableEpisodeIds.add(ep.id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) const filename = match ? match.path.split('/').pop() : null if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) } } } } const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const eps = season.episodes.map((ep: any) => { const epTorrent = episodeTorrentMap[ep.id] ?? null let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null if (fankai === null && serieData) { outer: for (const sd_season of serieData.seasons ?? []) { for (const sdEp of sd_season.episodes ?? []) { if (sdEp.id !== ep.id) continue for (const p of sdEp.paths ?? []) { const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null if (!ih) continue for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } } } } } } return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) } }) const total = eps.filter((e: any) => e.available).length const orgCount = eps.filter((e: any) => e.organized).length return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps } }) res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) }) } catch (err) { logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) // ── Download ─────────────────────────────────────────────────── app.post('/api/download', requireAuth, async (req, res) => { const url = req.body.magnet ?? req.body.torrent_url if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } logger.info('api', 'Téléchargement demandé') const results = await dispatchDownload(url) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) }) // ── Downloads list ───────────────────────────────────────────── app.get('/api/downloads', requireAuth, async (_req, res) => { try { const { category } = readSettings() const torrents = await dispatchList(category ?? 'fankai') let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const enriched = await Promise.all(torrents.map(async (t: any) => { const orgFiles = organized[t.hash] ?? {} let totalFiles = 1 try { const availableIds = await readAvailable() for (const id of availableIds) { const sd = await readSerieData(id) if (!sd) continue const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break } } } catch {} const doneFiles = Object.keys(orgFiles).length const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none' const notif = recentOrganized.find(n => n.hash === t.hash) return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] } })) res.json(enriched) } catch (err) { logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) // ── Organize ─────────────────────────────────────────────────── interface OrganizeNotif { hash: string; name: string; done: number; skipped: number; errors: number errorFiles: { file: string; error: string }[]; at: string } const recentOrganized: OrganizeNotif[] = [] const MAX_NOTIFS = 20 function pushNotif(n: OrganizeNotif) { recentOrganized.unshift(n) if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop() } app.get('/api/organize/recent', requireAuth, (_req, res) => { res.json(recentOrganized) }) app.post('/api/organize/recent/clear', requireAuth, (_req, res) => { recentOrganized.length = 0 logger.info('api', 'Historique des imports effacé') res.json({ ok: true }) }) app.post('/api/organize', requireAuth, async (req, res) => { const { hash, name, save_path } = req.body if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return } try { const seriesData = await loadEnrichedSeriesData() const result = await organizeTorrent(hash, name, save_path, seriesData) if (result.done > 0 || result.errors.length > 0) { pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() }) } res.json(result) } catch (err) { logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) // ── Logs ─────────────────────────────────────────────────────── app.get('/api/logs', requireAuth, (req, res) => { const limit = Number(req.query.limit) || 100 const level = (req.query.level as string) || 'all' const source = (req.query.source as string) || undefined res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() }) }) app.post('/api/logs/clear', requireAuth, (_req, res) => { clearLogs() logger.info('api', 'Logs effacés') res.json({ ok: true }) }) // ── Système ──────────────────────────────────────────────────── app.get('/api/system', requireAuth, (_req, res) => { res.json({ isDocker: fs.existsSync('/.dockerenv') }) }) app.get('/api/browse', requireAuth, (req, res) => { const dirPath = (req.query.path as string) || '/' try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }) const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b)) res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs }) } catch (err) { logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`) res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) } }) app.get('/api/torrents/status', requireAuth, async (_req, res) => { try { 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 }) } }) app.post('/api/update', requireAuth, async (_req, res) => { try { cacheClear() logger.info('api', 'Cache GitHub vidé — rechargement forcé') const availableIds = await readAvailable() if (!Array.isArray(availableIds)) throw new Error('available.json invalide') logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) res.json({ ok: true, count: availableIds.length }) } catch (err) { logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) app.post('/api/scan', requireAuth, async (_req, res) => { try { const { mediaPath } = readSettings() logger.info('api', 'Scan médiathèque manuel lancé') const seriesData = await loadEnrichedSeriesData() const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData) res.json({ ok: true, ...result }) } catch (err) { logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) if (fs.existsSync(PUBLIC_PATH)) { app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) }) } // ── Démarrage ────────────────────────────────────────────────── app.listen(PORT, async () => { logger.info('api', `Serveur démarré sur le port ${PORT}`) try { const available = await readAvailable() logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`) } catch (err) { logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`) } const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const { mediaPath } = readSettings() loadEnrichedSeriesData() .then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) 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() }) } } ) } catch (err) { logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`) } } setTimeout(() => { autoOrganize() setInterval(autoOrganize, 5 * 60_000) }, 10_000) })