feat(organize): vérification de complétion par fichier avant d'importer - Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent - Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver - Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink fix(organize): retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) fix(organize): suppression des entrées orphelines lors de la migration d'IDs - Quand l'ancien ID est orphelin et le nouvel ID déjà présent, l'orphelin est supprimé au lieu d'être conservé fix(qbittorrent): suppression de paused/stopped à l'ajout du torrent - Le torrent est ajouté normalement pour permettre la récupération immédiate des métadonnées - Premier poll de qbApplyFilePriority réduit à 100 ms feat(serie): badges épisodes et chapitres extraits du synopsis - Regex sur ep.plot pour extraire les plages "Ép. 628–634" et "Ch. 700–706" - La phrase de couverture est retirée du synopsis affiché - Badges violet (épisodes) et teal (chapitres) à côté des badges MULTI/VOSTFR feat(serie): lien wiki dans le header de la série si serie.wiki est défini fix(serie): header mobile — items-start + aspect-[2/3] sur le poster
165 lines
7.5 KiB
TypeScript
165 lines
7.5 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 } from './auth.js'
|
|
import { readSettings } from './settings.js'
|
|
import { registerDriver, dispatchList, dispatchRemove } 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 realDebridDriver from './torrent-clients/real-debrid.js'
|
|
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
|
|
import { logger } from './logger.js'
|
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
|
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
|
|
import { pushNotif } from './lib/notifs.js'
|
|
import { checkNfoUpdates } from './lib/nfo.js'
|
|
|
|
import settingsRouter from './routes/settings.js'
|
|
import torrentClientsRouter from './routes/torrent-clients.js'
|
|
import seriesRouter from './routes/series.js'
|
|
import downloadsRouter from './routes/downloads.js'
|
|
import organizeRouter from './routes/organize.js'
|
|
import importRouter from './routes/import.js'
|
|
import systemRouter from './routes/system.js'
|
|
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
|
import plexRouter from './routes/plex.js'
|
|
|
|
registerDriver(qbittorrentDriver)
|
|
registerDriver(transmissionDriver)
|
|
registerDriver(synologyDsDriver)
|
|
registerDriver(utorrentDriver)
|
|
registerDriver(rtorrentDriver)
|
|
registerDriver(realDebridDriver)
|
|
|
|
const app = express()
|
|
const PORT = Number(process.env.PORT) || 9898
|
|
|
|
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)
|
|
|
|
// ── Routes ─────────────────────────────────────────────────────
|
|
app.use('/api', settingsRouter)
|
|
app.use('/api', torrentClientsRouter)
|
|
app.use('/api', seriesRouter)
|
|
app.use('/api', downloadsRouter)
|
|
app.use('/api', organizeRouter)
|
|
app.use('/api', importRouter)
|
|
app.use('/api', systemRouter)
|
|
app.use('/api', nfoUpdatesRouter)
|
|
app.use('/api', plexRouter)
|
|
|
|
// ── 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(async seriesData => {
|
|
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
|
return 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,
|
|
async (result) => {
|
|
if (result.done > 0 || result.errors > 0) {
|
|
pushNotif({ ...result, at: new Date().toISOString() })
|
|
}
|
|
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
|
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
|
try {
|
|
await dispatchRemove(result.hash, false)
|
|
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
|
} catch (err) {
|
|
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
} 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)
|
|
|
|
// Sync automatique des noms de fichiers toutes les heures.
|
|
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
|
|
setInterval(async () => {
|
|
try {
|
|
const seriesData = await loadEnrichedSeriesData()
|
|
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
|
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
|
if (updated > 0)
|
|
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
|
|
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
|
if (renamed > 0)
|
|
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
|
} catch (err) {
|
|
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}, 60 * 60_000)
|
|
})
|