feat(ui): refonte de l'interface

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 3ede3e808c
commit 09eb1fa3aa
79 files changed
+5323 -3514

No files matched your search

+5 -2
View File
@@ -50,6 +50,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
let serieName: string | undefined
let serieId: number | undefined
let episodes: any[] = []
try {
const availableIds = await readAvailable()
@@ -61,6 +62,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
serieName = sd.title ?? sd.show_title
serieId = sd.id
episodes = resolved
break
}
@@ -68,8 +70,9 @@ router.get('/downloads', requireAuth, async (_req, res) => {
} 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 ?? [], serieName, episodes }
const notif = recentOrganized.find(n => n.hash === t.hash)
const imported = recentOrganized.find(n => n.hash === t.hash && n.done > 0)
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], importedAt: imported?.at ?? null, serieId, serieName, episodes }
}))
res.json(enriched)
} catch (err) {
+21
View File
@@ -531,6 +531,27 @@ router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
})
// ── Dossier série ──────────────────────────────────────────────
router.get('/organized-folders', requireAdmin, async (_req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath) { res.json([]); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
try {
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
for (const sd of await loadCatalog()) {
const title = sd.title ?? sd.show_title ?? ''
const expected = path.join(mediaPath, serieFolderName(title))
const stale = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
if (stale.length > 0) result.push({ serie_id: sd.id, serie_title: title, expected, current: stale })
}
res.json(result)
} catch (err) {
logger.error('api', `organized-folders échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId)
+10 -4
View File
@@ -3,8 +3,10 @@ import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadCatalog } from '../lib/serie-helpers.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { recentOrganized, pushNotif, clearNotifs } from '../lib/notifs.js'
import { dispatchGetFiles } from '../torrent-clients/index.js'
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule } from '../lib/schedule.js'
import { readSettings } from '../settings.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
@@ -23,11 +25,15 @@ router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
res.json(recentOrganized.slice(0, 20))
})
router.get('/organize/schedule', requireAuth, (_req, res) => {
res.json({ autoImport: readSettings().autoImport, intervalMs: AUTO_IMPORT_INTERVAL_MS, ...autoImportSchedule })
})
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
clearNotifs()
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true })
})
@@ -45,7 +51,7 @@ router.post('/organize', requireAuth, async (req, res) => {
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
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() })
pushNotif({ hash, name, serieId: result.serieId ?? null, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result)
} catch (err) {
+29 -72
View File
@@ -2,25 +2,41 @@ import { Router } from 'express'
import { requireAdmin } from '../auth.js'
import {
readRequests, upsertRequest, approveRequest,
rejectRequest, completeRequest, deleteRequest,
rejectRequest, completeRequest, deleteRequest, withdrawRequest,
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
type SerieRequest,
} from '../requests.js'
import { dispatchDownload } from '../torrent-clients/index.js'
import { readSerieData } from '../lib/github-cache.js'
import { resolveRequestTorrents, countRequestedEpisodes } from '../lib/request-scope.js'
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
const router = Router()
// GET /api/requests — admin voit tout, user voit les siennes
router.get('/requests', (req, res) => {
router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
: getRequestsForUser(req.user!.id)
res.json(list)
res.json(await withScope(list))
})
async function withScope(list: SerieRequest[]) {
const serieData = new Map<number, Promise<any | null>>()
return Promise.all(list.map(async r => {
if (!serieData.has(r.serieId)) serieData.set(r.serieId, readSerieData(r.serieId))
const sd = await serieData.get(r.serieId)
const seasons = mergedSeasons(r)
const episodes = mergedEpisodes(r)
return {
...r,
episodeCount: sd ? countRequestedEpisodes(sd, seasons, episodes) : null,
hasTorrents : !!sd && resolveRequestTorrents(sd, seasons, episodes).length > 0,
}
}))
}
// GET /api/requests/pending-count — pour le widget dashboard
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
@@ -119,75 +135,7 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
const toDownload: TorrentEntry[] = []
const seenHashes = new Set<string>()
// Tous les torrents pack (série + saisons) pour résoudre les ep.paths
const allPackTorrents: any[] = [
...(serieData.torrents ?? []),
...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
]
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
if (seenHashes.has(key)) return
seenHashes.add(key)
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
}
/**
* Ajoute le premier torrent disponible par épisode (individuel ou via file_index dans un pack).
* On prend le premier uniquement pour éviter les doublons quand plusieurs sources existent.
*/
function addEpisodesByIndex(epList: any[]) {
for (const ep of epList) {
if (ep.torrents?.length > 0) {
// Premier torrent individuel disponible
addTorrent(ep.torrents[0])
} else if (ep.paths?.length > 0) {
// Premier path dans un pack
const p = ep.paths[0]
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
}
}
}
if (episodes.length > 0) {
// ── Épisodes ciblés ───────────────────────────────────────
for (const season of serieData.seasons ?? []) {
const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
addEpisodesByIndex(targets)
}
} else if (seasons.length > 0) {
// ── Saisons ciblées ───────────────────────────────────────
for (const season of serieData.seasons ?? []) {
if (!seasons.includes(season.season_number)) continue
if ((season.torrents ?? []).length > 0) {
// Premier pack saison disponible → télécharger en bloc
addTorrent(season.torrents[0])
} else {
// Pas de pack → épisode par épisode avec file_index
addEpisodesByIndex(season.episodes ?? [])
}
}
} else {
// ── Toutes les saisons : intégrale en priorité ────────────
if ((serieData.torrents ?? []).length > 0) {
addTorrent(serieData.torrents[0])
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
// Packs saison (premier par saison)
for (const season of serieData.seasons ?? []) {
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
}
} else {
// Fallback : épisode par épisode avec file_index
for (const season of serieData.seasons ?? []) {
addEpisodesByIndex(season.episodes ?? [])
}
}
}
const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
if (toDownload.length === 0) {
logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`)
@@ -218,6 +166,15 @@ router.delete('/requests', requireAdmin, (_req, res) => {
res.json({ deleted: all.length })
})
// DELETE /api/requests/:id/mine — retire l'utilisateur d'une demande en attente
router.delete('/requests/:id/mine', (req, res) => {
try {
res.json(withdrawRequest(String(req.params.id), req.user!.id))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// DELETE /api/requests/:id — admin seulement
router.delete('/requests/:id', requireAdmin, (req, res) => {
try {
+16 -2
View File
@@ -10,12 +10,19 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac
import { loadSynced } from '../lib/rss-sync.js'
import {
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, countOrganizedEpisodes,
deduplicateEpisodes, serieHasEpisodes,
} from '../lib/serie-helpers.js'
import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router()
// Séries récemment ajoutées à la médiathèque
router.get('/library/recent', requireAuth, (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
res.json(recentImportsBySerie().slice(0, limit))
})
router.get('/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
@@ -30,12 +37,19 @@ router.get('/series', requireAuth, async (_req, res) => {
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) }))
const lastImports = new Map(recentImportsBySerie().map(r => [r.serieId, r.at]))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
const counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }
return {
...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0,
download_state: computeSerieDownloadState(serieData, organized, activeTorrents, counts),
episode_count: counts.total, organized_count: counts.organized, last_imported_at: lastImports.get(serie.id) ?? null,
in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id],
}
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
+8 -6
View File
@@ -9,9 +9,9 @@ import { systemInfo, checkPaths } from '../system.js'
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js'
import { cacheSize } from '../lib/github-cache.js'
import { cacheSize, cacheFetchedAt } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.js'
import { workerRunning, lastScan } from '../organize.js'
const router = Router()
@@ -129,7 +129,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
router.get('/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0, syncedAt: cacheFetchedAt('available.json') })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
@@ -173,6 +173,10 @@ router.post('/update', requireAuth, async (req, res) => {
}
})
router.get('/scan', requireAuth, (_req, res) => {
res.json({ lastScan })
})
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
@@ -188,8 +192,6 @@ router.post('/scan', requireAuth, async (_req, res) => {
// ── Debug ──────────────────────────────────────────────────────
router.get('/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)
@@ -205,7 +207,7 @@ router.get('/debug/stats', requireAuth, (req, res) => {
} 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: cacheSize(), ttlHours: 6 },
cache : { entries: cacheSize(), ttlHours: 1 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker : { running: workerRunning },