922 lines
48 KiB
TypeScript
922 lines
48 KiB
TypeScript
import express from 'express'
|
|
import cookieParser from 'cookie-parser'
|
|
import http from 'http'
|
|
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, updateClient
|
|
} from './torrent-clients/index.js'
|
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
|
import transmissionDriver from './torrent-clients/transmission.js'
|
|
import synologyDsDriver from './torrent-clients/synology-ds.js'
|
|
import utorrentDriver from './torrent-clients/utorrent.js'
|
|
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
|
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
|
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
|
import { systemInfo } from './system.js'
|
|
import { getGitlabTitle } from './gitlab-map.js'
|
|
|
|
registerDriver(qbittorrentDriver)
|
|
registerDriver(transmissionDriver)
|
|
registerDriver(synologyDsDriver)
|
|
registerDriver(utorrentDriver)
|
|
registerDriver(rtorrentDriver)
|
|
|
|
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)
|
|
|
|
app.get('/api/system/info', systemInfo)
|
|
|
|
// ── 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, devMode } = req.body
|
|
const patch: Record<string, any> = {}
|
|
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
|
if (completePath !== undefined) patch.completePath = completePath
|
|
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
|
if (category !== undefined) patch.category = category
|
|
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
|
if (autoImport !== undefined) patch.autoImport = autoImport
|
|
if (devMode !== undefined) patch.devMode = devMode
|
|
const updated = writeSettings(patch)
|
|
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.put('/api/torrent-clients/:uuid', 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 }
|
|
const oldClient = getClient(String(req.params.uuid))
|
|
if (oldClient) {
|
|
const driver = getDriver(type)
|
|
if (driver) {
|
|
for (const field of driver.definition.fields) {
|
|
if (field.type === 'password' && config[field.key] === '••••••••') {
|
|
config[field.key] = oldClient.config[field.key]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const updated = updateClient(String(req.params.uuid), name, type, config)
|
|
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
|
res.json(sanitizeClient(updated))
|
|
})
|
|
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
|
const { type, config, uuid } = 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 }
|
|
if (uuid) {
|
|
const oldClient = getClient(uuid)
|
|
if (oldClient) {
|
|
for (const field of driver.definition.fields) {
|
|
if (field.type === 'password' && config[field.key] === '••••••••') {
|
|
config[field.key] = oldClient.config[field.key]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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<T> { data: T; expiresAt: number }
|
|
const _cache = new Map<string, CacheEntry<any>>()
|
|
|
|
function cacheGet<T>(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<T>(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<any> {
|
|
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<number[]> {
|
|
try { return await githubGet('available.json') as number[] }
|
|
catch { return [] }
|
|
}
|
|
|
|
async function readInfohashMap(): Promise<Record<string, string>> {
|
|
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
|
catch { return {} }
|
|
}
|
|
|
|
async function readSerieData(serieId: number): Promise<any | null> {
|
|
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<any> {
|
|
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 loadEnrichedSeriesData(): Promise<any[]> {
|
|
const ids = await readAvailable()
|
|
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
|
return allSd.filter(Boolean)
|
|
}
|
|
|
|
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
|
if (!serieData) return 'none'
|
|
const allTorrents = extractTorrentsFromSerieData(serieData)
|
|
|
|
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
|
|
|
// Collecter tous les épisodes de la série
|
|
const allEpisodeIds = new Set<number>()
|
|
for (const season of serieData.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
allEpisodeIds.add(ep.id)
|
|
}
|
|
}
|
|
|
|
const organizedEpisodeIds = new Set<number>()
|
|
|
|
// Vérifier via les torrents
|
|
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)) {
|
|
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
|
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
|
|
}
|
|
}
|
|
|
|
// Vérifier aussi les imports manuels (clé "manual" dans organized.json)
|
|
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 (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<number>(availableIds)
|
|
let organized: Record<string, Record<string, any>> = {}
|
|
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<string>()
|
|
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); 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<number, any>()
|
|
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 serieData = serieDataMap.get(serie.id) ?? null
|
|
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
|
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<string, Record<string, any>> = {}
|
|
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<number>()
|
|
const episodeTorrentMap : Record<number, any> = {}
|
|
const seasonTorrentMapBySn : Record<number, any> = {}
|
|
const integraleTorrents : any[] = []
|
|
const organizedEpisodeIds = new Set<number>()
|
|
|
|
// Vérifier les imports manuels (clé "manual") pour tous les épisodes de la série
|
|
const manualOrg = organized['manual'] ?? {}
|
|
if (serieData) {
|
|
for (const season of serieData.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
|
if (isOrg) 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) {
|
|
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
|
if (isOrg) 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 isOrg = orgFiles[String(ep.id)] !== undefined
|
|
if (!isOrg) {
|
|
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)
|
|
} else {
|
|
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 infohashMap = await readInfohashMap()
|
|
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
|
|
let organized: Record<string, Record<string, any>> = {}
|
|
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/version', (_req, res) => {
|
|
try {
|
|
const versionPath = path.join(BASE_DIR, 'version.txt')
|
|
const version = fs.existsSync(versionPath)
|
|
? fs.readFileSync(versionPath, 'utf-8').trim()
|
|
: 'dev'
|
|
res.json({ version })
|
|
} catch {
|
|
res.json({ version: 'dev' })
|
|
}
|
|
})
|
|
|
|
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/browse-files', requireAuth, (req, res) => {
|
|
const dirPath = (req.query.path as string) || '/'
|
|
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
|
try {
|
|
const files: { name: string; path: string; size: number }[] = []
|
|
function walk(dir: string) {
|
|
let entries: fs.Dirent[]
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
|
catch { return }
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith('.')) continue
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
walk(full)
|
|
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
|
try {
|
|
const stat = fs.statSync(full)
|
|
files.push({ name: entry.name, path: full, size: stat.size })
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
walk(dirPath)
|
|
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
|
res.json({ path: dirPath, files })
|
|
} catch (err) {
|
|
logger.warn('api', `browse-files "${dirPath}" : ${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' })
|
|
}
|
|
})
|
|
|
|
// ── Import manuel ──────────────────────────────────────────────
|
|
app.post('/api/manual-import', requireAuth, async (req, res) => {
|
|
const { serie_id, items } = req.body
|
|
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
|
res.status(400).json({ error: 'serie_id et items requis' }); return
|
|
}
|
|
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
|
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
|
let organized: Record<string, Record<string, any>> = {}
|
|
try { if (fs.existsSync(ORGANIZED_PATH)) organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
|
|
const sd = await readSerieData(Number(serie_id))
|
|
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
|
const rawTitle = sd.title ?? sd.show_title ?? ''
|
|
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
|
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
|
for (const season of sd.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
episodeIndex.set(ep.id, { ep, season })
|
|
}
|
|
}
|
|
const done: number[] = []
|
|
const errors: { file: string; error: string }[] = []
|
|
for (const item of items) {
|
|
const { file_path, episode_id, hash } = item
|
|
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
|
|
const found = episodeIndex.get(Number(episode_id))
|
|
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
|
|
const { ep, season } = found
|
|
const srcFilename = path.basename(file_path)
|
|
const srcExt = path.extname(srcFilename)
|
|
const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
|
|
const resolvedName = nfoSupport
|
|
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
|
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
|
// FIX : swap l'extension avec celle du fichier source (.nfo/.mkv → .mp4 si besoin)
|
|
const resolvedExt = path.extname(resolvedName)
|
|
const destFilename = resolvedExt && resolvedExt !== srcExt
|
|
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
|
: resolvedName
|
|
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
|
const destPath = path.join(destDir, destFilename)
|
|
try {
|
|
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
|
fs.mkdirSync(destDir, { recursive: true })
|
|
if (!fs.existsSync(destPath)) {
|
|
if (organizeMode === 'hardlink') {
|
|
try { fs.linkSync(file_path, destPath) }
|
|
catch { await fs.promises.copyFile(file_path, destPath) }
|
|
} else if (organizeMode === 'move') {
|
|
await fs.promises.copyFile(file_path, destPath)
|
|
await fs.promises.unlink(file_path)
|
|
} else {
|
|
// copy
|
|
await fs.promises.copyFile(file_path, destPath)
|
|
}
|
|
}
|
|
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
|
if (!organized[torrentHash]) organized[torrentHash] = {}
|
|
organized[torrentHash][String(episode_id)] = {
|
|
at: new Date().toISOString(), season: season.season_number,
|
|
episode: ep.episode_number, episode_id: ep.id,
|
|
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
|
}
|
|
done.push(episode_id)
|
|
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
|
errors.push({ file: srcFilename, error: msg })
|
|
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
|
|
}
|
|
}
|
|
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
|
|
|
|
// Télécharger les NFO en arrière-plan si activé
|
|
if (nfoSupport && done.length > 0) {
|
|
const gitlabTitle = getGitlabTitle(serieTitle)
|
|
;(async () => {
|
|
try {
|
|
let files: any[] = [], page = 1
|
|
while (true) {
|
|
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
|
if (!Array.isArray(batch) || batch.length === 0) break
|
|
files.push(...batch); if (batch.length < 100) break; page++
|
|
}
|
|
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
|
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
|
const dest = path.join(mediaPath, serieTitle, rel)
|
|
if (fs.existsSync(dest)) continue
|
|
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
|
if (!raw.ok) continue
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
|
|
}
|
|
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
|
|
} catch (err) {
|
|
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
})()
|
|
}
|
|
|
|
res.json({ ok: true, done: done.length, errors })
|
|
})
|
|
|
|
app.get('/api/organized/:serieId', requireAuth, async (req, res) => {
|
|
const serieId = Number(req.params.serieId)
|
|
try {
|
|
const sd = await readSerieData(serieId)
|
|
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
|
let organized: Record<string, Record<string, any>> = {}
|
|
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
|
const result: Record<string, any> = {}
|
|
for (const season of sd.seasons ?? []) {
|
|
for (const ep of season.episodes ?? []) {
|
|
for (const p of ep.paths ?? []) {
|
|
if (typeof p !== 'object' || !p.infohash) continue
|
|
const hash = p.infohash.toLowerCase()
|
|
const entry = (organized[hash] ?? {})[String(ep.id)]
|
|
if (entry) { result[String(ep.id)] = entry; break }
|
|
}
|
|
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
|
|
result[String(ep.id)] = organized['manual'][String(ep.id)]
|
|
}
|
|
}
|
|
}
|
|
res.json(result)
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
|
}
|
|
})
|
|
|
|
// ── Debug ──────────────────────────────────────────────────────
|
|
app.get('/api/debug/stats', requireAuth, (req, res) => {
|
|
const { devMode } = readSettings()
|
|
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
|
const mem = process.memoryUsage()
|
|
const uptimeS = Math.floor(process.uptime())
|
|
const h = Math.floor(uptimeS / 3600)
|
|
const m = Math.floor((uptimeS % 3600) / 60)
|
|
const s = uptimeS % 60
|
|
let organizedCount = 0
|
|
try {
|
|
const orgPath = path.join(DATA_DIR, 'organized.json')
|
|
if (fs.existsSync(orgPath)) {
|
|
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
|
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
|
|
}
|
|
} catch {}
|
|
res.json({
|
|
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
|
|
cache : { entries: _cache.size, ttlHours: 6 },
|
|
uptime : `${h}h ${m}m ${s}s`,
|
|
uptimeSeconds: uptimeS,
|
|
worker : { running: workerRunning },
|
|
organized: { trackedFiles: organizedCount },
|
|
requests : { notifs: recentOrganized.length },
|
|
})
|
|
})
|
|
|
|
// ── NFO Updates ────────────────────────────────────────────────
|
|
interface NfoUpdateNotif {
|
|
serieTitle: string; commitSha: string; commitMsg: string
|
|
updatedAt: string; status: 'updated' | 'error'; error?: string
|
|
}
|
|
const recentNfoUpdates: NfoUpdateNotif[] = []
|
|
const MAX_NFO_NOTIFS = 30
|
|
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
|
|
const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
|
const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
|
|
|
function loadNfoCommits(): Record<string, string> {
|
|
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
|
|
catch { return {} }
|
|
}
|
|
function saveNfoCommits(commits: Record<string, string>) {
|
|
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
|
|
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
|
|
}
|
|
|
|
async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
|
|
try {
|
|
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
|
|
if (!res.ok) return null
|
|
const data: any[] = await res.json()
|
|
if (!data.length) return null
|
|
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
|
|
} catch { return null }
|
|
}
|
|
|
|
async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
|
|
let files: any[] = [], page = 1
|
|
while (true) {
|
|
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
|
if (!Array.isArray(batch) || batch.length === 0) break
|
|
files.push(...batch); if (batch.length < 100) break; page++
|
|
}
|
|
let downloaded = 0, skipped = 0
|
|
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
|
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
|
const destPath = path.join(destRoot, relativePath)
|
|
try {
|
|
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
|
if (!res.ok) { skipped++; continue }
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
|
|
downloaded++
|
|
} catch { skipped++ }
|
|
}
|
|
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
|
|
}
|
|
|
|
async function checkNfoUpdates() {
|
|
const { mediaPath, nfoSupport } = readSettings()
|
|
if (!nfoSupport) return
|
|
let organized: Record<string, Record<string, any>> = {}
|
|
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
|
if (Object.keys(organized).length === 0) return
|
|
const seriesData = await loadEnrichedSeriesData()
|
|
const knownHashes = new Set(Object.keys(organized))
|
|
const serieTitles = new Set<string>()
|
|
for (const sd of seriesData) {
|
|
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
|
|
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
|
|
const rawTitle = sd.title ?? sd.show_title ?? ''
|
|
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
|
|
}
|
|
}
|
|
if (serieTitles.size === 0) return
|
|
const commits = loadNfoCommits()
|
|
let hasChanges = false
|
|
for (const serieTitle of serieTitles) {
|
|
const gitlabTitle = getGitlabTitle(serieTitle)
|
|
const latest = await getLatestGitlabCommit(gitlabTitle)
|
|
if (!latest || commits[gitlabTitle] === latest.sha) continue
|
|
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
|
|
try {
|
|
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
|
|
commits[gitlabTitle] = latest.sha
|
|
hasChanges = true
|
|
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
|
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
|
|
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
|
|
}
|
|
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
|
|
}
|
|
if (hasChanges) saveNfoCommits(commits)
|
|
}
|
|
|
|
app.get('/api/nfo-updates/recent', requireAuth, (_req, res) => { res.json(recentNfoUpdates) })
|
|
app.post('/api/nfo-updates/check', requireAuth, async (_req, res) => {
|
|
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
|
|
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
|
|
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
|
|
})
|
|
|
|
// ── Catch-all SPA ──────────────────────────────────────────────
|
|
if (fs.existsSync(PUBLIC_PATH)) {
|
|
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
|
}
|
|
|
|
// ── Démarrage ──────────────────────────────────────────────────
|
|
const server = http.createServer({ maxHeaderSize: 32768 }, app)
|
|
|
|
server.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()
|
|
|
|
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
|
|
try {
|
|
if (fs.existsSync(ORGANIZED_PATH)) {
|
|
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
|
const isOldFormat = Object.values(raw).some((entries: any) =>
|
|
Object.values(entries).some(v => typeof v === 'string')
|
|
)
|
|
if (isOldFormat) {
|
|
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
|
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
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 infohashMap = await readInfohashMap()
|
|
const seriesData = await loadEnrichedSeriesData()
|
|
await autoOrganizeAll(
|
|
() => dispatchList(category ?? 'fankai', infohashMap),
|
|
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)
|
|
|
|
setTimeout(() => {
|
|
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
|
|
setInterval(() => {
|
|
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
|
|
}, 60 * 60_000)
|
|
}, 30_000)
|
|
}) |