feat(ui): refonte de l'interface
This commit is contained in:
1 parent
3ede3e808c
commit
09eb1fa3aa
79 files changed
+5323
-3514
No files matched your search
+2
-1
@@ -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) })
|
||||
|
||||
+7
-4
@@ -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(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const GITHUB_BASE =
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
interface CacheEntry<T> { data: T; expiresAt: number; fetchedAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
@@ -19,7 +19,13 @@ export function cacheGet<T>(key: string): T | null {
|
||||
}
|
||||
|
||||
export function cacheSet<T>(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() }
|
||||
|
||||
+46
-4
@@ -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<number, RecentSerieImport>()
|
||||
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()]
|
||||
}
|
||||
@@ -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<string>()
|
||||
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -212,12 +212,20 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
export function computeSerieDownloadState(
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, any>>,
|
||||
activeTorrents: Set<string>,
|
||||
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<string, Record<string, any>>): { 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<string>()
|
||||
@@ -232,7 +240,7 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
|
||||
const organizedEpKeys = new Set<string>()
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user