diff --git a/server/index.ts b/server/index.ts index d2e50ba..c4b1a6f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' import { organizeTorrent, autoOrganizeAll } from './organize.js' +import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' registerDriver(qbittorrentDriver) @@ -108,16 +109,73 @@ function indexBySerie(torrents: any[]): Map { } // ── Series ───────────────────────────────────────────────────── + +function computeSerieDownloadState( + serieTorrents : any[], + organized : Record>, + activeTorrents: Set // hashes actuellement dans qB en downloading +): 'none' | 'downloading' | 'partial' | 'complete' { + if (serieTorrents.length === 0) return 'none' + + // Un torrent de la série est en cours de téléchargement ? + const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase())) + if (hasActive) return 'downloading' + + // Compter les fichiers attendus vs organisés + let totalFiles = 0 + let doneFiles = 0 + for (const t of serieTorrents) { + const files: any[] = t.torrent_files ?? [] + if (files.length === 0) { + // Fichier unique + totalFiles++ + if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++ + } else { + totalFiles += files.length + doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length + } + } + + if (totalFiles === 0) return 'none' + if (doneFiles === 0) return 'none' + if (doneFiles >= totalFiles) return 'complete' + return 'partial' +} + app.get('/api/series', requireAuth, async (_req, res) => { try { const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) const byId = indexBySerie(torrents) + + // Charger organized.json + let organized: Record> = {} + try { + const orgPath = path.join(process.cwd(), 'data', 'organized.json') + if (fs.existsSync(orgPath)) + organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) + } catch {} + + // Récupérer les hashes actifs depuis qB (best effort) + const activeTorrents = new Set() + try { + 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()) + } + } catch {} + const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? []) - res.json({ series: seriesList.map((serie: any) => ({ - ...serie, - torrent_count: byId.get(serie.id)?.length ?? 0, - has_torrents : byId.has(serie.id) - }))}) + res.json({ series: seriesList.map((serie: any) => { + const serieTorrents = byId.get(serie.id) ?? [] + return { + ...serie, + torrent_count : serieTorrents.length, + has_torrents : serieTorrents.length > 0, + download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents), + } + })}) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } @@ -256,7 +314,12 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { let organizeState: 'none' | 'partial' | 'done' = 'none' if (doneFiles >= totalFiles) organizeState = 'done' else if (doneFiles > 0) organizeState = 'partial' - return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles } } + + // Récupérer les erreurs depuis recentOrganized + const notif = recentOrganized.find(n => n.hash === t.hash) + const errorFiles = notif?.errorFiles ?? [] + + return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles } }) res.json(enriched) @@ -267,12 +330,13 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { // ── Résultats d'organisation récents (pour notifications frontend) ── interface OrganizeNotif { - hash : string - name : string - done : number - skipped : number - errors : number - at : string + hash : string + name : string + done : number + skipped : number + errors : number + errorFiles : { file: string; error: string }[] + at : string } const recentOrganized: OrganizeNotif[] = [] const MAX_NOTIFS = 20 @@ -308,6 +372,21 @@ app.post('/api/organize', requireAuth, async (req, res) => { }) // ── Torrents status ──────────────────────────────────────────── +// ── 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 + const entries = readLogs({ limit, level: level as any, source }) + res.json({ entries, size: logsFileSize() }) +}) + +app.post('/api/logs/clear', requireAuth, (_req, res) => { + clearLogs() + logger.info('api', 'Logs effacés') + res.json({ ok: true }) +}) + // ── System info ──────────────────────────────────────────────── app.get('/api/system', requireAuth, (_req, res) => { const isDocker = fs.existsSync('/.dockerenv') diff --git a/server/logger.ts b/server/logger.ts new file mode 100644 index 0000000..b4cbc74 --- /dev/null +++ b/server/logger.ts @@ -0,0 +1,105 @@ +/** + * logger.ts + * ========= + * Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event) + * Niveaux : debug | info | warn | error + * En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque. + */ + +import fs from 'fs' +import path from 'path' + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +export interface LogEntry { + at : string // ISO timestamp + level : LogLevel + source : string // 'organize' | 'api' | 'scraper' | ... + msg : string + meta? : Record +} + +const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl') +const MAX_LINES = 2000 // rotation auto au-delà +const IS_PROD = process.env.NODE_ENV === 'production' + +// ── Écriture ────────────────────────────────────────────────── + +function writeLine(entry: LogEntry) { + try { + fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8') + // Rotation si trop grand + const content = fs.readFileSync(LOGS_PATH, 'utf-8') + const lines = content.split('\n').filter(Boolean) + if (lines.length > MAX_LINES) { + fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8') + } + } catch {} +} + +// ── API publique ────────────────────────────────────────────── + +export function log(level: LogLevel, source: string, msg: string, meta?: Record) { + const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta } + + // Console + const prefix = `[${source}]` + if (level === 'error') console.error(prefix, msg, meta ?? '') + else if (level === 'warn') console.warn(prefix, msg, meta ?? '') + else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '') + else if (level === 'info') console.log(prefix, msg, meta ?? '') + + // Disque — pas les debug en prod + if (level === 'debug' && IS_PROD) return + writeLine(entry) +} + +export const logger = { + debug: (source: string, msg: string, meta?: Record) => log('debug', source, msg, meta), + info : (source: string, msg: string, meta?: Record) => log('info', source, msg, meta), + warn : (source: string, msg: string, meta?: Record) => log('warn', source, msg, meta), + error: (source: string, msg: string, meta?: Record) => log('error', source, msg, meta), +} + +// ── Lecture pour l'API ──────────────────────────────────────── + +export interface LogsReadOptions { + limit? : number // nb de lignes (défaut 100) + level? : LogLevel | 'all' // filtre niveau + source? : string // filtre source +} + +export function readLogs(opts: LogsReadOptions = {}): LogEntry[] { + try { + if (!fs.existsSync(LOGS_PATH)) return [] + const content = fs.readFileSync(LOGS_PATH, 'utf-8') + let lines = content.split('\n').filter(Boolean) + + // Parse + let entries: LogEntry[] = [] + for (const line of lines) { + try { entries.push(JSON.parse(line)) } catch {} + } + + // Filtres + if (opts.level && opts.level !== 'all') + entries = entries.filter(e => e.level === opts.level) + if (opts.source) + entries = entries.filter(e => e.source === opts.source) + + // Plus récents en premier + entries.reverse() + + // Limite + const limit = opts.limit ?? 100 + return entries.slice(0, limit) + } catch { return [] } +} + +export function clearLogs(): void { + try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {} +} + +export function logsFileSize(): number { + try { return fs.statSync(LOGS_PATH).size } catch { return 0 } +} \ No newline at end of file diff --git a/server/organize.ts b/server/organize.ts index 402b821..0e8d48b 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -25,7 +25,7 @@ export interface OrganizeResult { */ export async function autoOrganizeAll( listFn : () => Promise, - onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number }) => void + 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') @@ -60,11 +60,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, + 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é') diff --git a/src/router/index.ts b/src/router/index.ts index be14be5..3e1b7c2 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -25,6 +25,10 @@ const router = createRouter({ { path: '/settings', component: () => import('@/views/SettingsView.vue') + }, + { + path: '/logs', + component: () => import('@/views/LogsView.vue') } ], scrollBehavior(to, _from, savedPosition) { diff --git a/src/views/CatalogView.vue b/src/views/CatalogView.vue index f623705..775974f 100644 --- a/src/views/CatalogView.vue +++ b/src/views/CatalogView.vue @@ -8,7 +8,18 @@ Fankarr
- + + + + + + + + + + @@ -126,6 +137,27 @@ {{ serie.torrent_count }}
+ +
+ + + + + + + + + + + + + {{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }} +
+
Voir @@ -170,15 +202,19 @@ const activeFilter = ref('all') const activeDownloads = ref(0) const filters = [ - { label: 'Toutes', value: 'all' }, + { label: 'Toutes', value: 'all' }, { label: 'Disponibles', value: 'available' }, + { label: 'Téléchargés', value: 'complete' }, + { label: 'En cours', value: 'downloading' }, { label: 'Sans torrent', value: 'unavailable' }, ] const filtered = computed(() => { let list = store.series - if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents) - if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents) + if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents) + if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents) + if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete') + if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial') if (search.value.trim()) { const q = search.value.toLowerCase() list = list.filter(s => s.title.toLowerCase().includes(q)) @@ -188,7 +224,7 @@ const filtered = computed(() => { async function handleLogout() { await auth.logout() - await router.push('/auth') + router.push('/auth') } // ── Polling downloads actifs + notifications organisation ────── diff --git a/src/views/DownloadsView.vue b/src/views/DownloadsView.vue index e12979b..08b4d5a 100644 --- a/src/views/DownloadsView.vue +++ b/src/views/DownloadsView.vue @@ -105,7 +105,25 @@
- + + +
+ + {{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }} + + + +
+ + +
+ + +
+
+ +

Logs

+
+
+ {{ formatSize(fileSize) }} + + +
+
+ + +
+ + +
+ +
+ + + + + +
+ AFFICHER + +
+
+ + +
+
+ Chargement... +
+ + +
+ Aucun log +
+ + +
+
+ + + + + + + + + + + + + + + + + +
HORODATAGENIVEAUSOURCEMESSAGE
{{ formatDate(entry.at) }} + + {{ entry.level.toUpperCase() }} + + {{ entry.source }} + {{ entry.msg }} + + {{ JSON.stringify(entry.meta) }} + +
+
+ + +
+ {{ entries.length }} entrées affichées + {{ formatSize(fileSize) }} sur disque +
+
+ + +
+
+

Effacer les logs ?

+

Cette action est irréversible.

+
+ + +
+
+
+ +
+ + + \ No newline at end of file