diff --git a/server/auth.ts b/server/auth.ts index 330e2e8..8a8725d 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -7,6 +7,18 @@ import { JWT_SECRET } from './secret.js' import { DATA_DIR } from './config.js' import { logger } from './logger.js' +// AUTH_TOKEN_EXPIRY : durée de validité du token JWT +// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration +const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d' +const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' +const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY + +function signToken(username: string): string { + return NO_EXPIRY + ? jwt.sign({ username }, JWT_SECRET) + : jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any }) +} + const DATA_PATH = path.join(DATA_DIR, 'auth.json') const SALT_ROUNDS = 10 @@ -66,7 +78,7 @@ export function authSetup(req: Request, res: Response): void { writeAuth({ username, passwordHash }) logger.info('auth', `Compte créé pour "${username}"`) - const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) + const token = signToken(username) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.json({ success: true }) } @@ -91,9 +103,9 @@ export function authLogin(req: Request, res: Response): void { return } - const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) + const token = signToken(username) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) - logger.info('auth', `Connexion réussie pour "${username}"`) + logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`) res.json({ success: true }) } diff --git a/server/config.ts b/server/config.ts index 7615319..a8df75f 100644 --- a/server/config.ts +++ b/server/config.ts @@ -4,6 +4,37 @@ import path from 'path' export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' && path.dirname((process as any).execPath) !== process.cwd() +// ── Chargement anticipé du fichier .env pour les binaires bun ───────────── +// Doit s'exécuter avant tout autre module qui lit process.env +// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde. +;(() => { + if (!_isBunBinary) return + const binDir = path.dirname((process as any).execPath) + const envPath = path.join(binDir, '.env') + if (!fs.existsSync(envPath)) return + try { + const lines = fs.readFileSync(envPath, 'utf-8').split('\n') + for (const raw of lines) { + const line = raw.trim() + if (!line || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq === -1) continue + const key = line.slice(0, eq).trim() + let val = line.slice(eq + 1).trim() + // Supprimer les guillemets optionnels + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1) + } + // Ne pas écraser les variables déjà définies (CLI/système prioritaires) + if (key && !(key in process.env)) process.env[key] = val + } + console.log(`[config] .env chargé depuis ${envPath}`) + } catch (err) { + console.warn(`[config] Impossible de charger .env : ${err}`) + } +})() + export const BASE_DIR = _isBunBinary ? path.dirname((process as any).execPath) : process.cwd() diff --git a/server/routes/downloads.ts b/server/routes/downloads.ts index bc1e2b7..d4266e2 100644 --- a/server/routes/downloads.ts +++ b/server/routes/downloads.ts @@ -13,12 +13,15 @@ import { recentOrganized } from '../lib/notifs.js' const router = Router() router.post('/download', requireAuth, async (req, res) => { - const url = req.body.magnet ?? req.body.torrent_url + // Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers + const torrent_url: string | null = req.body.torrent_url ?? null + const magnet : string | null = req.body.magnet ?? null + const url = torrent_url ?? magnet if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null - const file_path: string | null = req.body.file_path ?? null - logger.info('api', `Téléchargement demandé${file_index != null ? ` (fichier #${file_index})` : ''}`) - const results = await dispatchDownload(url, { file_index, file_path }) + const file_path : string | null = req.body.file_path ?? null + logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_index != null ? ` (fichier #${file_index})` : ''}`) + const results = await dispatchDownload(url, { file_index, file_path, magnet }) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) }) diff --git a/server/routes/series.ts b/server/routes/series.ts index 36fcc0c..b2a750a 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -51,8 +51,8 @@ router.get('/series/:id', requireAuth, async (req, res) => { try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const availableEpisodeIds = new Set() - const episodeTorrentMap : Record = {} - const seasonTorrentMapBySn : Record = {} + const episodeTorrentMap : Record = {} + const seasonTorrentMapBySn : Record = {} const integraleTorrents : any[] = [] const organizedEpisodeIds = new Set() @@ -85,7 +85,8 @@ router.get('/series/:id', requireAuth, async (req, res) => { } for (const season of serieData.seasons ?? []) { for (const t of (season.torrents ?? [])) { - seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } + if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = [] + seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }) const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} @@ -96,8 +97,9 @@ router.get('/series/:id', requireAuth, async (req, res) => { } for (const ep of season.episodes ?? []) { for (const t of (ep.torrents ?? [])) { + if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = [] const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) - episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null } + episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null }) availableEpisodeIds.add(ep.id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const isOrg = orgFiles[String(ep.id)] !== undefined @@ -110,15 +112,20 @@ router.get('/series/:id', requireAuth, async (req, res) => { } } // Fallback : si l'épisode n'a pas de torrent individuel, - // on crée un torrent synthétique depuis ep.paths (fichier dans un pack) - if (!episodeTorrentMap[ep.id]) { + // on crée un torrent synthétique depuis ep.paths (fichier dans un pack). + // On ajoute TOUTES les entrées valides (un pack par path unique). + if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) { + const seenPackHashes = new Set() for (const pathEntry of (ep.paths ?? [])) { if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue - const pack = torrentByHash.get(pathEntry.infohash.toLowerCase()) + const hashKey = pathEntry.infohash.toLowerCase() + if (seenPackHashes.has(hashKey)) continue + const pack = torrentByHash.get(hashKey) if (!pack) continue - episodeTorrentMap[ep.id] = { torrent_url: pack.torrent_url, magnet: pack.magnet, type: pack.type, raw: pack.raw, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null } + seenPackHashes.add(hashKey) + if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = [] + episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, type: pack.type, raw: pack.raw, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null }) availableEpisodeIds.add(ep.id) - break } } } @@ -127,7 +134,8 @@ router.get('/series/:id', requireAuth, async (req, res) => { const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const eps = season.episodes.map((ep: any) => { - const epTorrent = episodeTorrentMap[ep.id] ?? null + const epTorrents = episodeTorrentMap[ep.id] ?? [] + const epTorrent = epTorrents[0] ?? null let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null if (fankai === null && serieData) { outer: for (const sd_season of serieData.seasons ?? []) { @@ -142,7 +150,15 @@ router.get('/series/:id', requireAuth, async (req, res) => { } } } - return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) } + const resolvedFankai = fankai ?? true + return { + ...ep, + available: availableEpisodeIds.has(ep.id), + torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null, + torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })), + fankai, + organized: organizedEpisodeIds.has(ep.id), + } }) const total = eps.filter((e: any) => e.available).length const orgCount = eps.filter((e: any) => e.organized).length @@ -154,7 +170,15 @@ router.get('/series/:id', requireAuth, async (req, res) => { : total > 0 && orgCount >= total ? 'complete' : 'partial' - return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgState, organized_count: orgCount, episodes: eps } + const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? [] + return { + ...season, + torrent: seasonTorrents[0] ?? null, + torrents: seasonTorrents, + organized_state: orgState, + organized_count: orgCount, + episodes: eps, + } }) res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) }) diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 67c1bf4..8a9708f 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -58,7 +58,8 @@ export interface TorrentInfo { export interface DownloadOptions { file_index?: number | null - file_path?: string | null + file_path ?: string | null + magnet ?: string | null // conservé pour extraire le hash même quand on envoie le .torrent } export interface TorrentClientDriver { diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 1e14354..dcfa9ee 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -216,7 +216,9 @@ const QB: TorrentClientDriver = { async add(config, url, options?: DownloadOptions) { const sid = await qbLogin(config) + // Extraire le hash depuis l'URL (si magnet) ou depuis options.magnet (si .torrent URL) const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) + ?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) const hash = hashMatch?.[1]?.toLowerCase() ?? null if (options?.file_index != null && hash) { diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index ccb6a95..1e7cdf4 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -290,6 +290,7 @@ const RT: TorrentClientDriver = { async add(config, url, options?: DownloadOptions) { const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) + ?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) const hash = hashMatch?.[1]?.toUpperCase() ?? null if (options?.file_index != null && hash) { diff --git a/src/components/serie/SerieHero.vue b/src/components/serie/SerieHero.vue index 6484422..377c650 100644 --- a/src/components/serie/SerieHero.vue +++ b/src/components/serie/SerieHero.vue @@ -25,42 +25,7 @@ Séries - -
-

{{ serie.title }}

- - - -
- -
- - -
-
-
+

{{ serie.title }}

@@ -77,28 +42,14 @@

{{ serie.plot }}

-
diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index a7fd602..03d80bc 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -33,7 +33,7 @@
+ + +
+ +
+ +
+
@@ -110,6 +134,30 @@ + +
+ +
+ +
+
+