diff --git a/index.html b/index.html index df41c5b..26b3a84 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,12 @@ - + - + + + + Fankarr diff --git a/server/auth.ts b/server/auth.ts index 6017e9a..1a23341 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -8,7 +8,7 @@ import { DATA_DIR } from './config.js' import { logger } from './logger.js' import { findByUsername, findById, findByApiToken, - createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, + createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin, type User, } from './users.js' import { readSettings } from './settings.js' @@ -34,6 +34,7 @@ function signToken(user: User): string { } function setCookieAndRespond(res: Response, user: User): void { + user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt const token = signToken(user) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.json({ success: true, user: safeUser(user) }) diff --git a/server/index.ts b/server/index.ts index 3eebe13..6ce75ea 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,6 +22,7 @@ import { readAvailable, readInfohashMap } from './lib/github-cache.js' import { loadCatalog } from './lib/serie-helpers.js' import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js' import { pushNotif } from './lib/notifs.js' +import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js' import { readRequests, completeRequest } from './requests.js' import { checkNfoUpdates } from './lib/nfo.js' import { runJellyfinSync } from './routes/jellyfin.js' @@ -96,7 +97,7 @@ const ADMIN_PREFIXES = [ '/users', '/jellyfin', '/settings', '/torrent-clients', '/downloads', '/download', '/organize', '/import', '/system', '/nfo-updates', '/plex', '/rss-sync', - '/torrent', '/manual-import', '/organized', '/organized-summary', + '/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders', '/rename-episode', '/rename-all', '/purge-nfo', '/logs', '/browse', '/browse-files', '/update', '/scan', '/debug', ] @@ -119,10 +120,9 @@ app.use('/api', systemRouter) app.use('/api', nfoUpdatesRouter) app.use('/api', plexRouter) app.use('/api', rssSyncRouter) +app.use('/api', invitesRouter) app.use('/api', requireAuth, seriesRouter) app.use('/api', requireAuth, requestsRouter) -// Invites : mix public + admin (le router gère ses propres middlewares) -app.use('/api', invitesRouter) // ── Catch-all SPA ────────────────────────────────────────────── if (fs.existsSync(PUBLIC_PATH)) { @@ -188,6 +188,8 @@ server.listen(PORT, async () => { .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) const autoOrganize = async () => { + autoImportSchedule.lastRunAt = new Date().toISOString() + planNextAutoImport() try { const { category } = readSettings() const infohashMap = await readInfohashMap() @@ -230,9 +232,10 @@ server.listen(PORT, async () => { } } + planNextAutoImport(10_000) setTimeout(() => { autoOrganize() - setInterval(autoOrganize, 5 * 60_000) + setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS) }, 10_000) setTimeout(() => { diff --git a/server/lib/github-cache.ts b/server/lib/github-cache.ts index ddae1a3..4d96dba 100644 --- a/server/lib/github-cache.ts +++ b/server/lib/github-cache.ts @@ -8,7 +8,7 @@ const GITHUB_BASE = const CACHE_TTL_MS = 1 * 60 * 60 * 1000 -interface CacheEntry { data: T; expiresAt: number } +interface CacheEntry { data: T; expiresAt: number; fetchedAt: number } const _cache = new Map>() export function cacheGet(key: string): T | null { @@ -19,7 +19,13 @@ export function cacheGet(key: string): T | null { } export function cacheSet(key: string, data: T): void { - _cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }) + _cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() }) +} + +// Date de la dernière lecture d'une clé +export function cacheFetchedAt(key: string): string | null { + const entry = _cache.get(key) + return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null } export function cacheClear(): void { _cache.clear() } diff --git a/server/lib/notifs.ts b/server/lib/notifs.ts index 4860501..3dd850c 100644 --- a/server/lib/notifs.ts +++ b/server/lib/notifs.ts @@ -1,12 +1,54 @@ +import fs from 'fs' +import path from 'path' +import { DATA_DIR } from '../config.js' + export interface OrganizeNotif { - hash: string; name: string; done: number; skipped: number; errors: number + hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number errorFiles: { file: string; error: string }[]; at: string } -export const recentOrganized: OrganizeNotif[] = [] -const MAX_NOTIFS = 20 +export interface RecentSerieImport { serieId: number; episodes: number; at: string } + +const HISTORY_PATH = path.join(DATA_DIR, 'import-history.json') +const MAX_HISTORY = 200 +const RECENT_WINDOW_MS = 7 * 24 * 60 * 60_000 + +function loadHistory(): OrganizeNotif[] { + try { + if (!fs.existsSync(HISTORY_PATH)) return [] + const data = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8')) + return Array.isArray(data) ? data.slice(0, MAX_HISTORY) : [] + } catch { return [] } +} + +function saveHistory(): void { + try { + fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true }) + fs.writeFileSync(HISTORY_PATH, JSON.stringify(recentOrganized, null, 2)) + } catch {} +} + +// Du plus récent au plus ancien +export const recentOrganized: OrganizeNotif[] = loadHistory() export function pushNotif(n: OrganizeNotif) { recentOrganized.unshift(n) - if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop() + if (recentOrganized.length > MAX_HISTORY) recentOrganized.length = MAX_HISTORY + saveHistory() +} + +export function clearNotifs() { + recentOrganized.length = 0 + saveHistory() +} + +export function recentImportsBySerie(): RecentSerieImport[] { + const bySerie = new Map() + for (const n of recentOrganized) { + if (n.serieId == null || n.done <= 0) continue + const current = bySerie.get(n.serieId) + if (!current) { bySerie.set(n.serieId, { serieId: n.serieId, episodes: n.done, at: n.at }); continue } + if (Date.parse(current.at) - Date.parse(n.at) <= RECENT_WINDOW_MS) current.episodes += n.done + } + return [...bySerie.values()] } diff --git a/server/lib/request-scope.ts b/server/lib/request-scope.ts new file mode 100644 index 0000000..131d73b --- /dev/null +++ b/server/lib/request-scope.ts @@ -0,0 +1,90 @@ +export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null } + +/** + * Torrents à envoyer pour une portée de demande. + * episodes non vide → épisodes ciblés ; sinon seasons non vide → saisons ciblées ; sinon toute la série puis voila + */ +export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] { + const toDownload: RequestTorrent[] = [] + const seenHashes = new Set() + + 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 + */ + 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 ?? []) + } + } + } + + return toDownload +} + +// Épisodes uniques concernés par la même portée +export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number { + const keys = new Set() + for (const season of serieData.seasons ?? []) { + for (const ep of season.episodes ?? []) { + const inScope = episodes.length > 0 + ? episodes.includes(ep.id) + : seasons.length === 0 || seasons.includes(season.season_number) + if (inScope) keys.add(`${season.season_number}:${ep.episode_number}`) + } + } + return keys.size +} diff --git a/server/lib/schedule.ts b/server/lib/schedule.ts new file mode 100644 index 0000000..4c89645 --- /dev/null +++ b/server/lib/schedule.ts @@ -0,0 +1,11 @@ +// Cadence de l'import automatique +export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000 + +export const autoImportSchedule = { + lastRunAt: null as string | null, + nextRunAt: null as string | null, +} + +export function planNextAutoImport(delayMs = AUTO_IMPORT_INTERVAL_MS) { + autoImportSchedule.nextRunAt = new Date(Date.now() + delayMs).toISOString() +} diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index 9d306f1..6667144 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -212,12 +212,20 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb return resolved } -export function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' { +export function computeSerieDownloadState( + serieData : any | null, + organized : Record>, + activeTorrents: Set, + counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }, +): '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' + if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading' + if (counts.total === 0 || counts.organized === 0) return 'none' + if (counts.organized >= counts.total) return 'complete' + return 'partial' +} +export function countOrganizedEpisodes(serieData: any, organized: Record>): { total: number; organized: number } { // Dédoublonnage : épisodes uniques par (season_number, episode_number) // pour éviter de compter x264 + x265 comme deux épisodes distincts const uniqueEpKeys = new Set() @@ -232,7 +240,7 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco const organizedEpKeys = new Set() - for (const t of allTorrents) { + for (const t of extractTorrentsFromSerieData(serieData)) { const hash = t.infohash?.toLowerCase() if (!hash) continue const orgFiles = organized[hash] ?? {} @@ -250,7 +258,5 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco for (const [id, key] of epIdToKey) if (trackedIds.has(String(id))) organizedEpKeys.add(key) - if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none' - if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete' - return 'partial' + return { total: uniqueEpKeys.size, organized: organizedEpKeys.size } } diff --git a/server/organize.ts b/server/organize.ts index 89e20c1..2cb1aca 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -22,6 +22,7 @@ function resolveWorkerPath(): string { } export interface OrganizeResult { + serieId?: number | null total : number skipped : number done : number @@ -85,6 +86,8 @@ function swapExt(filename: string, ext: string): string { return filename.slice(0, -cur.length) + ext } +export let lastScan: { at: string; found: number; added: number } | null = null + export async function scanMediaPath( mediaPath : string, organizedPath: string, @@ -288,6 +291,7 @@ export async function scanMediaPath( writeOrganized(organized, organizedPath) } + lastScan = { at: new Date().toISOString(), ...result } logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`) return result @@ -637,6 +641,7 @@ export async function organizeTorrent( _pendingManual.delete(key) try { await runWorker([fakeTorrent], seriesData, msg => { + result.serieId = msg.serieId ?? null result.total = msg.total result.skipped = msg.skipped result.done = msg.done diff --git a/server/requests.ts b/server/requests.ts index 440a5d6..4eaa1c2 100644 --- a/server/requests.ts +++ b/server/requests.ts @@ -170,6 +170,26 @@ export function deleteRequest(id: string): void { writeRequests(requests.filter(r => r.id !== id)) } +/** Retire un utilisateur surune demande en attente et la demande disparaît s'il était seul demandeur */ +export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } { + const requests = readRequests() + const req = requests.find(r => r.id === id) + if (!req || !req.requesters.some(r => r.userId === userId)) throw new Error('Demande introuvable') + if (req.status !== 'pending') throw new Error('Seule une demande en attente peut être annulée') + + const username = req.requesters.find(r => r.userId === userId)!.username + req.requesters = req.requesters.filter(r => r.userId !== userId) + if (req.requesters.length === 0) { + writeRequests(requests.filter(r => r.id !== id)) + logger.info('requests', `Demande "${req.serieName}" annulée par "${username}"`) + return { deleted: true, request: null } + } + req.updatedAt = new Date().toISOString() + writeRequests(requests) + logger.info('requests', `"${username}" retiré de la demande "${req.serieName}"`) + return { deleted: false, request: req } +} + export function getRequestsForUser(userId: string): SerieRequest[] { return readRequests().filter(r => r.requesters.some(req => req.userId === userId)) } diff --git a/server/routes/downloads.ts b/server/routes/downloads.ts index a6ea862..1360d1e 100644 --- a/server/routes/downloads.ts +++ b/server/routes/downloads.ts @@ -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) { diff --git a/server/routes/import.ts b/server/routes/import.ts index 4e5ea6a..a4796a7 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -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) diff --git a/server/routes/organize.ts b/server/routes/organize.ts index 23fe835..7a938c3 100644 --- a/server/routes/organize.ts +++ b/server/routes/organize.ts @@ -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) { diff --git a/server/routes/requests.ts b/server/routes/requests.ts index 4f96ce9..bdf4c28 100644 --- a/server/routes/requests.ts +++ b/server/routes/requests.ts @@ -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>() + 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() - - // 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 { diff --git a/server/routes/series.ts b/server/routes/series.ts index 4d2cacf..fb44901 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -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() 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}`) diff --git a/server/routes/system.ts b/server/routes/system.ts index 39d0c72..4ea504a 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -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 }, diff --git a/server/users.ts b/server/users.ts index 8823315..643e93a 100644 --- a/server/users.ts +++ b/server/users.ts @@ -20,6 +20,7 @@ export interface User { apiToken : string createdAt : string tourSeenAt? : string | null + lastLoginAt?: string | null } const USERS_PATH = path.join(DATA_DIR, 'users.json') @@ -144,6 +145,15 @@ export function changePassword(id: string, currentPassword: string, newPassword: logger.info('users', `Mot de passe changé pour "${user.username}"`) } +export function markLogin(id: string): string | null { + const users = readUsers() + const user = users.find(u => u.id === id) + if (!user) return null + user.lastLoginAt = new Date().toISOString() + writeUsers(users) + return user.lastLoginAt +} + export function markTourSeen(id: string): string { const users = readUsers() const user = users.find(u => u.id === id) diff --git a/src/assets/main.css b/src/assets/main.css index b46e2ee..57b61ea 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -1,18 +1,19 @@ @import "tailwindcss"; -/* ─── Tokens custom consommables via Tailwind v4 ─── */ +/* ─── Des polices un peu stylées si vous voulez mon avis ─── */ @theme { + --font-sans: 'Zen Kaku Gothic New', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif; + --font-display: 'Shippori Mincho', 'Yu Mincho', Georgia, serif; + --color-accent: var(--accent); --color-accent-hover: var(--accent-hover); - --color-accent-dim: var(--accent-dim); --color-accent-muted: var(--accent-muted); + --color-on-accent: var(--on-accent); - --color-shell: var(--bg-shell); --color-sidebar: var(--bg-sidebar); --color-main: var(--bg-main); --color-card: var(--bg-card); --color-hover: var(--bg-hover); - --color-active: var(--bg-active); --color-border: var(--border); --color-border-light: var(--border-light); @@ -21,85 +22,55 @@ --color-secondary: var(--text-secondary); --color-muted: var(--text-muted); - --color-gold: var(--gold); + --color-ok: var(--ok); + --color-err: var(--err); + + --radius-card: 14px; + --radius-field: 10px; + --radius-poster: 6px; + + --text-body: 13.5px; + --text-body--line-height: 1.5; + --text-meta: 12.5px; + --text-meta--line-height: 1.45; } -/* ─── Thème Indigo Électrique (défaut) ─── */ -:root, -[data-theme="indigo"] { - --accent: #7B7EF8; - --accent-hover: #9496fa; - --accent-dim: #1c1c38; - --accent-muted: rgba(123, 126, 248, 0.15); +/* ─── Palette ─── */ +:root { + --bg-sidebar: #0e0a08; + --bg-main: #13100c; + --bg-card: #1d1814; + --bg-hover: #27221d; - --bg-shell: #111116; - --bg-sidebar: #0d0d14; - --bg-main: #16161e; - --bg-card: #111116; - --bg-hover: #1c1c28; - --bg-active: #22223a; + --border: #352f2a; + --border-light: #27221d; - --border: #2a2a3a; - --border-light: #1e1e2e; + --text-primary: #efe9dd; + --text-secondary: #b8b0a3; + --text-muted: #948b80; - --text-primary: #e8e8f8; - --text-secondary: #9898b8; - --text-muted: #55556a; + --gold: #f2c44a; + --ok: #89d298; + --err: #ea8470; + --on-accent: #0e0a08; - --gold: #f5c842; + --accent: #f2c44a; + --accent-hover: color-mix(in srgb, var(--accent) 80%, white); + --accent-muted: color-mix(in srgb, var(--accent) 14%, transparent); } -/* ─── Thème Crimson Night ─── */ -[data-theme="crimson"] { - --accent: #E74C3C; - --accent-hover: #f06050; - --accent-dim: #2a0e0e; - --accent-muted: rgba(231, 76, 60, 0.15); - - --bg-shell: #110e0e; - --bg-sidebar: #0d0a0a; - --bg-main: #161010; - --bg-card: #110e0e; - --bg-hover: #1e1414; - --bg-active: #2a1616; - - --border: #2e2020; - --border-light: #201616; - - --text-primary: #f0e8e8; - --text-secondary: #b89898; - --text-muted: #6a5555; - - --gold: #f5c842; -} - -/* ─── Thème Teal Froid ─── */ -[data-theme="teal"] { - --accent: #00CDBF; - --accent-hover: #1de8da; - --accent-dim: #001e1c; - --accent-muted: rgba(0, 205, 191, 0.15); - - --bg-shell: #0a0f0f; - --bg-sidebar: #060c0c; - --bg-main: #0e1616; - --bg-card: #0a0f0f; - --bg-hover: #101e1e; - --bg-active: #142828; - - --border: #1a2e2e; - --border-light: #121e1e; - - --text-primary: #e0f0f0; - --text-secondary: #88b0b0; - --text-muted: #4a6868; - - --gold: #f5c842; -} +/* ─── Thèmes ─── */ +[data-theme="vermillon"] { --accent: #e8734f; } +[data-theme="jade"] { --accent: #6fc79a; } /* ─── Base globale ─── */ *, *::before, *::after { box-sizing: border-box; } +html { + color-scheme: dark; + accent-color: var(--accent); +} + html, body, #app { height: 100%; margin: 0; @@ -109,41 +80,211 @@ html, body, #app { body { background: var(--bg-main); color: var(--text-primary); + font-family: var(--font-sans); -webkit-font-smoothing: antialiased; } -::-webkit-scrollbar { width: 6px; } +:where(a, button, [role="switch"], summary):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } /* ─── Composants réutilisables ─── */ @layer components { + /* Typo */ + .page-title { + @apply font-display text-[28px] leading-tight font-bold text-primary; + } + .section-title { + @apply font-display text-[21px] leading-snug font-bold text-primary; + } + .card-title { + @apply text-[15px] font-bold text-primary; + } + .tag-label { + @apply text-[10.5px] font-bold uppercase tracking-[0.14em] text-muted; + } + + /* Boutons */ + .btn-primary, + .btn-secondary, + .btn-ghost, + .btn-danger { + @apply inline-flex items-center justify-center gap-2 h-10 px-[18px] rounded-full text-body whitespace-nowrap + transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed; + } .btn-primary { - @apply bg-accent text-white px-4 py-2 text-sm rounded-lg - hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer; + @apply px-5 bg-accent text-on-accent font-bold hover:bg-accent-hover; } - .btn-secondary { - @apply border border-border text-secondary px-4 py-2 text-sm rounded-lg - hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer; + @apply border border-border text-primary font-semibold hover:bg-hover; } - .btn-ghost { - @apply text-muted px-2 py-1 text-sm rounded-md - hover:text-primary hover:bg-hover transition-colors cursor-pointer; + @apply border border-border-light text-secondary font-medium hover:bg-hover hover:text-primary; + } + .btn-danger { + @apply border border-err/30 text-err font-semibold hover:bg-err/10; + } + .btn-icon { + @apply inline-flex items-center justify-center shrink-0 w-10 h-10 rounded-full border border-border-light text-secondary + transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-sm { + @apply h-8 px-3.5 text-meta; + } + .btn-icon.btn-sm { + @apply w-8 px-0; } - .settings-card { - @apply bg-card border border-border rounded-xl p-4; + /* Cartes et champs */ + .card { + @apply bg-card rounded-card px-5 py-4; } - .settings-label { - @apply text-xs text-muted block; + .field-label { + @apply block text-meta text-secondary; } - .settings-input { - @apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg - outline-none focus:border-accent transition-colors; + .field { + @apply w-full bg-main border border-border-light text-primary text-body px-3.5 py-2.5 rounded-field + placeholder:text-muted outline-none focus:border-accent transition-colors; } -} \ No newline at end of file + + /* Pastilles d'état */ + .pill { + @apply inline-flex items-center gap-[7px] h-6 px-[11px] rounded-full border border-transparent + text-[11.5px] font-bold whitespace-nowrap; + } + .pill-ok { @apply border-ok/30 text-ok; } + .pill-active { @apply bg-accent-muted text-accent; } + .pill-wait { @apply border-accent/35 text-accent; } + .pill-neutral { @apply border-border text-secondary; } + .pill-err { @apply border-err/35 text-err; } + .pill-muted { @apply border-border-light text-muted; } + + /* Filtres et onglets compteurs */ + .chip { + @apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium + text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover; + } + .chip-count { + @apply text-muted; + } + .chip.is-active { + @apply border-primary bg-primary text-on-accent font-bold hover:bg-primary; + } + .chip.is-active .chip-count { + @apply text-on-accent/55 font-medium; + } + .chip-row { + @apply flex items-center gap-1.5 flex-wrap; + } + .chip-row > * { + @apply shrink-0; + } + /* Sur mobile, les filtres défilent sur une ligne jusqu'aux bords de l'écran */ + @media (width < 48rem) { + .chip-row { + @apply flex-nowrap overflow-x-auto -mx-4 px-4 -my-1 py-1; + scrollbar-width: none; + } + } + + /* Bascule */ + .switch { + @apply relative inline-flex shrink-0 w-[46px] h-[26px] p-[3px] rounded-full bg-hover transition-colors cursor-pointer + disabled:opacity-50 disabled:cursor-not-allowed; + } + .switch-knob { + @apply block w-5 h-5 rounded-full bg-muted transition-transform duration-200; + } + .switch[aria-checked="true"] { + @apply bg-accent; + } + .switch[aria-checked="true"] .switch-knob { + @apply translate-x-5 bg-on-accent; + } + + /* Sélecteur */ + .segmented { + @apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light; + } + .segmented-item { + @apply inline-flex items-center h-[26px] px-3.5 rounded-full text-meta text-secondary whitespace-nowrap + transition-colors cursor-pointer hover:text-primary; + } + .segmented-item.is-active { + @apply bg-accent text-on-accent font-bold hover:text-on-accent; + } + .segmented-lg { + @apply h-[38px]; + } + .segmented-lg .segmented-item { + @apply h-[30px] px-4 text-[13px]; + } + + /* Barres de progression */ + .progress { + @apply h-[3px] rounded-[2px] bg-hover overflow-hidden; + } + .progress-bar { + @apply h-full rounded-[2px] bg-accent transition-[width] duration-500; + } + + /* Menus déroulants et pop */ + .menu { + @apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5; + } + .menu-item { + @apply flex items-center gap-2.5 w-full min-h-9 px-3 py-2 rounded-lg text-left text-body text-secondary + transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed; + } + .modal-backdrop { + @apply fixed inset-0 z-50 flex items-center justify-center px-4 bg-sidebar/75; + } + .modal { + @apply w-full bg-card border border-border rounded-card p-6 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.55)]; + } + + + .stamp-new { + @apply inline-block px-2 pt-[3px] pb-[2px] border-2 border-accent bg-sidebar/85 text-accent + font-display text-[11px] font-bold leading-none tracking-[0.12em] -rotate-6; + } + + @media (pointer: coarse) { + .btn-primary, + .btn-secondary, + .btn-ghost, + .btn-danger { + @apply h-11; + } + .btn-icon { + @apply w-11 h-11; + } + .btn-sm { + @apply h-10; + } + .btn-icon.btn-sm { + @apply w-10; + } + .chip { + @apply h-10; + } + .menu-item { + @apply min-h-11; + } + } + + .dot-grid { + @apply absolute inset-0 pointer-events-none; + background-image: radial-gradient(color-mix(in srgb, var(--accent) 12%, transparent) 1px, transparent 1.5px); + background-size: 7px 7px; + mask-image: linear-gradient(var(--dot-grid-angle, 90deg), #000 0%, transparent var(--dot-grid-fade, 55%)); + } +} diff --git a/src/components/EpisodeRow.vue b/src/components/EpisodeRow.vue deleted file mode 100644 index 9e4e6f4..0000000 --- a/src/components/EpisodeRow.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/src/components/FolderPicker.vue b/src/components/FolderPicker.vue index a7c58c5..7f07933 100644 --- a/src/components/FolderPicker.vue +++ b/src/components/FolderPicker.vue @@ -1,32 +1,30 @@