From b7c7e9d33dd6190cb97a82a685d414601c353e6e Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Sat, 23 May 2026 19:05:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(search):=20ajout=20des=20requetes=20deja?= =?UTF-8?q?=20demand=C3=A9=20si=20existante=20sur=20la=20serie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes/public-api.ts | 959 +++++++----------------------------- 1 file changed, 189 insertions(+), 770 deletions(-) diff --git a/server/routes/public-api.ts b/server/routes/public-api.ts index d2e4af8..005b138 100644 --- a/server/routes/public-api.ts +++ b/server/routes/public-api.ts @@ -1,790 +1,209 @@ /** - * FanKarr Jellyfin Plugin - * ======================== - * Injecte une section "Découvrir sur FanKaï" dans la page de recherche Jellyfin. + * API publique v1 — pour le plugin Jellyfin et intégrations tierces + * Auth : Bearer {apiToken} dans le header Authorization */ -(async function FanKarr() { - 'use strict'; +import { Router } from 'express' +import { findByApiToken } from '../users.js' +import { validateJellyfinToken } from '../lib/jellyfin.js' +import { readUsers, safeUser } from '../users.js' +import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js' +import { autoDownloadRequest } from './requests.js' +import { readSettings } from '../settings.js' +import { readAvailable } from '../lib/github-cache.js' +import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js' +import { logger } from '../logger.js' +import type { Request, Response, NextFunction } from 'express' - const FANKAI_LOGO = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNTAgMTUwIj48c3R5bGU+LmNscy0ye2ZpbGw6Y3VycmVudENvbG9yfTwvc3R5bGU+PGc+PHBhdGggY2xhc3M9ImNscy0yIiBkPSJtMTEzLjg1LDEyLjhjLTEuOTQsMy41Ny0zLjgsNy4wOS01Ljc2LDEwLjU1LTMuNiw2LjM1LTcuMzEsMTIuNjUtMTAuOSwxOS4wMS0xLjY5LDMtMy4yMiw2LjA4LTQuNDQsOS40OSwzLjI1LTIuNDYsNi40NC01LjAzLDkuNzgtNy4zNiwyLjkyLTIuMDQsNS45NS0zLjk2LDkuMDctNS42Nyw4LjU5LTQuNzIsMTkuMjktMS43OCwyNC40LDYuNTQuNDIuNjgsMS4zLDEuMTYsMS41MiwxLjg3LjI2LjgzLjA1LDIuNjIsMCwyLjYyLTMuOTQuMTMtMy40NSw1Ljc5LTcuNTksNS44OS0zLjQ2LjA4LTYuOSwxLjI1LTEwLjM3LDEuMzctNi43OC4yNS0xMy40OC42NC0xOS4zMiw0LjU3LTEuOTEsMS4yOS0zLjUsMy4wNS00Ljk3LDQuMzYuMjEuODYuMTksMS4xMy4zLDEuMiw0LjQyLDIuNyw4Ljc5LDUuNDksMTMuMzEsOCwyLjExLDEuMTcsMy42NiwzLjc3LDIuODIsNi42My0xLjE4LDMuOTctMi42NCw3Ljg2LTQuMDQsMTEuNzYtLjQ4LDEuMzMtMS4xMSwyLjYxLTEuODgsNC40MSw1Ljg3LDIuMTQsMTEuNjMsNC4yNiwxNy40MSw2LjM1LDMuNDMsMS4yNCw2Ljg2LDIuNDcsMTAuMzEsMy42NSw3LjM4LDIuNTIsOS4xNiw5LjU0LDExLjUyLDE1LjUzLDEuMDQsMi42NS0uNzUsNi43NS0yLjExLDkuODEtLjQ2LDEuMDQtMy41MywxLjIyLTUuNDIsMS4yNi0xLjc4LjAzLTMuNTctLjY0LTQuNDYtLjgzLTUuOTctOC40LTExLjU0LTE1Ljk4LTIzLjI4LTE0LjcuMS0uMzIuMjEtLjY1LjMxLS45NywyLjMyLS41Miw0LjY0LTEuMDUsNi45Ny0xLjU3LjAzLS4yMi4wNi0uNDUuMDktLjY3LTIuOTctMS4xOC01Ljk0LTIuMzctOC45Mi0zLjUyLTEuMzMtLjUyLTIuNy0uOTMtNC4wMy0xLjQ1LTQuODMtMS45LTQuNzktMS44OS04LjI2LDIuMjctNC4zMSw1LjE3LTEwLjIyLDguMTMtMTYuMywxMS4wMS42OS0uODQsMS4zMS0xLjc1LDIuMS0yLjQ4Ljc2LS43MSwxLjY5LTEuMjIsMi4yNi0yLjI5LTQuOSwxLjg3LTkuODEsMy43NC0xNC43MSw1LjYxLS4xMy0uMjItLjI2LS40NS0uMzktLjY3Ljg2LS42MiwxLjcyLTEuMjQsMi41OC0xLjg1LS4wNC0uMTctLjA4LS4zNC0uMTItLjUxLTIuOTQuNjUtNS45MywxLjExLTguOCwyLTQuMTgsMS4yOS04LjI3LDIuODctMTIuMzgsNC4zOC0yLjU5Ljk1LTUuMTIsMS41MS03LjE5LS4xNCwxMS44NC05LjQsMjUuMTYtMTcuMiwzNi41OS0yOC45My00LjIxLTMuNjUtOC4xLTcuMDEtMTEuOTgtMTAuMzguMTUtLjQzLjMtLjg3LjQ1LTEuMywxLjYxLS4wOSwzLjMtLjU1LDQuODEtLjIxLDQuMTQuOTIsOC4yMSwyLjE4LDEyLjY2LDMuNCwzLjU4LTYuNiw3LjEtMTMuMjksNy4zLTIwLjU4LTYuNjgtMi42NS05Ljc5LDQuMTMtMTUuNyw1Ljc2LDIuNTUtMi45Miw0LjU0LTUuMTksNi41Mi03LjQ2LS4yLS4xNy0uMzktLjMzLS41OS0uNS01LjU4LDQuMjgtMTYuODksMTYuMDYtMjQuMTYsMjkuMDctLjE5LS4yOS0uMzctLjU5LS41NS0uODctLjgxLDEuNDYtMS41NywyLjgzLTIuMzMsNC4yMWwtMS4wNC0uMzVjMS41OC01LjcxLDIuODYtMTEuNTMsNC44MS0xNy4xMSwyLjY3LTcuNjcsNi4zMi0xNS4wMiw4LjcxLTIyLjc2LDQuODctMTUuOCwxMy43NS0yOS42NywyMC43LTQ0LjQ1LjY1LTEuMzgsMS4yOC0yLjc3LDEuODgtNC4xNywxLjU4LTMuNjgsMy45Ny03LjM3LS40Ny0xMS4xMy4zLS4xNi41OS0uMzMuODktLjQ5LDcuMTMsNC4wOCwxNC4yNSw4LjE1LDIyLjM4LDEyLjhaIi8+PHBhdGggY2xhc3M9ImNscy0yIiBkPSJtNy4wMyw3MS43MmMuMjUtLjQ4LjM5LTEuMjEuNzctMS4zOCwxLjEtLjQ5LDIuNjItMS4zOCwzLjQxLS45OCwyLjU4LDEuMjgsNC44LjM4LDcuMDYtLjQ4LDIuNzYtMS4wNCw1LjU3LTIuMDUsOC4xNC0zLjQ3LDEuMjItLjY3LDIuNjMtMi4xOSwyLjc3LTMuNDYsMS4wMy05LjY1LDMuNi0xOC44Myw3LjU0LTI3LjY3LjI0LS41My4zLTEuMTQtLjE3LTIuMDMtMS4wMSwxLjYzLTEuNzgsMy40OS0zLjA4LDQuODUtMi4wOCwyLjIxLTQuNDIsNC4yLTYuODIsNi4wNy0xLjM5LDEuMDktMy4wMiwyLjA1LTQuNywyLjU0LTEuMzEuMzktMy4yNi41MS00LjI0LS4yLTQuMzItMy4xMy04LjM4LTYuNi0xMi43MS0xMC4wOSwyLjAzLTIuNDgsMy43My00LjIsNy4xOC0zLjUsMS43NC4zNiw0LjA0LS40Myw1LjY3LTEuNCw1LjQzLTMuMjEsMTAuNjUtNi43OSwxNi4wMS0xMC4xMyw1LjU4LTMuNDgsMTEuNDUtMy40NiwxNy4xMy0uNTMsNC41LDIuMzIsOC43Myw1LjE5LDEzLjAzLDcuOSwxLjQ3LjkzLDIuMzUsMi4yMywxLjM0LDQuMTEtMi4wOCwzLjg4LTQuMDEsNy44NS02LjIyLDExLjY2LTEuOTUsMy4zNy00LjIsNi41Ni02LjQ3LDEwLjA2LDIuMDQuOTYsMy43OCwxLjY3LDUuNCwyLjU4LDIuMjUsMS4yNywzLjI1LDMuMjQsMi45Nyw1Ljg3LS4zMywzLjE1LTEuNTksNS42NC00LjY4LDYuODQtLjg0LjMzLTEuODMuNzItMi42NS41Ni01LjgtMS4xLTkuNzksMi45My0xNC40Myw1LjA0LTEuMi41NC0yLjE5LDEuODMtMi45NSwyLjk5LTMuMyw1LjA4LTguODMsNi43LTEzLjgzLDkuMTctLjUzLjI2LTEuMjIuNzQtMS4zNCwxLjI0LTEuNzYsNy44Mi0zLjQ1LDE1LjY1LTQuNTgsMjQuMDksNC4xLTIuMzIsOC4xOS00LjY1LDEyLjI5LTYuOTcuMTIuMTUuMjMuMzEuMzUuNDYtMS4wNSwxLjE3LTIuMSwyLjM0LTMuMTUsMy41MmwuNS42NmM1LjczLTIuNjEsMTAuOTctNS45OCwxNS4yNC0xMC44Ny4yOC4yMi41Ny40My44NS42NS0xLjgzLDMuMzktMy43OCw2LjcyLTUuNDQsMTAuMi0uOTUsMS45OS0xLjM4LDQuMjEtMi4xMyw2LjMtLjQ4LDEuMzMtLjg0LDIuODItMS43MSwzLjg2LTIuNjEsMy4xMy01LjQ2LDYuMDYtOC4xOSw5LjA5LS40NS41LS43NiwxLjEyLTEuMTQsMS42OC0uNjYuOTktMS4yMSwyLjA5LTIuMDIsMi45NC0uMzkuNDEtMS41OC43MS0xLjg1LjQ2LTIuMjYtMi4xNC01LjUsMi4yNS03Ljc4LTEuNTItMS45LTMuMTMtNS4zNS01LjMzLTcuMjMtOC40Ny0yLjM3LTMuOTUtNS43NS03Ljg0LTUuMTQtMTIuODUuNTEtNC4xNywyLjE2LTguMTgsMi44OS0xMi4zNCwxLjQzLTguMTEsMi42LTE2LjI3LDMuODItMjQuNDIuMTQtLjk1LS4xMS0xLjk1LS4xOS0yLjkzLjE0LjA5LjI5LjE3LjQzLjI2WiIvPjwvZz48L3N2Zz4K'; +const router = Router() - // --------------------------------------------------------------------------- - // 1. Config - // --------------------------------------------------------------------------- +// ── Middleware auth API token ───────────────────────────────── - let API_URL = ''; - let fankarrToken = sessionStorage.getItem('fankarr_token') || ''; +function requireApiToken(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization + if (!header?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Token requis (Authorization: Bearer )' }); return + } + const user = findByApiToken(header.slice(7)) + if (!user) { + res.status(401).json({ error: 'Token invalide' }); return + } + req.user = user + next() +} +// ── POST /api/v1/auth/jellyfin ──────────────────────────────── +// Échange un token Jellyfin contre un token FanKarr +// Body: { jellyfinUserId, jellyfinToken } +router.post('/v1/auth/jellyfin', async (req, res) => { + const { jellyfinUserId, jellyfinToken } = req.body + if (!jellyfinUserId || !jellyfinToken) { + res.status(400).json({ error: 'jellyfinUserId et jellyfinToken requis' }); return + } + + const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken) + if (!info.valid) { + res.status(401).json({ error: info.error ?? 'Authentification Jellyfin échouée' }); return + } + + // Trouver l'utilisateur FanKarr correspondant (par username) + const users = readUsers() + const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase()) + if (!fanUser) { + logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`) + res.status(404).json({ + error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`, + }); return + } + + logger.info('public-api', `SSO Jellyfin réussi pour "${fanUser.username}"`) + res.json({ + token : fanUser.apiToken, + username: fanUser.username, + role : fanUser.role, + }) +}) + +// ── GET /api/v1/auth/me ─────────────────────────────────────── +router.get('/v1/auth/me', requireApiToken, (req, res) => { + res.json(safeUser(req.user!)) +}) + +// ── GET /api/v1/series/search?q= ───────────────────────────── +// Recherche dans le catalogue FanKai +router.get('/v1/series/search', requireApiToken, async (req, res) => { + const q = String(req.query.q ?? '').trim().toLowerCase() try { - const res = await fetch('/FanKarrSearch/config'); - const data = await res.json(); - API_URL = (data.ApiUrl || data.apiUrl || '').replace(/\/$/, ''); - } catch (e) { - console.error('[FanKarr] Impossible de charger la config :', e); - return; - } + const [apiData, availableIds] = await Promise.all([ + fankaiGet('/series'), + readAvailable(), + ]) + const availableSet = new Set(availableIds) + const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) - if (!API_URL) { - console.warn('[FanKarr] Aucune URL API configurée. Configure le plugin dans le Dashboard.'); - return; - } - - // --------------------------------------------------------------------------- - // 2. Auth - // --------------------------------------------------------------------------- - - async function authenticate() { - const credentials = localStorage.getItem('jellyfin_credentials'); - if (!credentials) { console.warn('[FanKarr] Token Jellyfin introuvable.'); return false; } - const parsed = JSON.parse(credentials); - const jellyfinToken = parsed?.Servers?.[0]?.AccessToken; - const jellyfinUserId = parsed?.Servers?.[0]?.UserId; - if (!jellyfinToken || !jellyfinUserId) { console.warn('[FanKarr] Token ou UserId introuvable.'); return false; } - try { - const res = await fetch(`${API_URL}/api/v1/auth/jellyfin`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jellyfinToken, jellyfinUserId }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - fankarrToken = data.token || data.accessToken || ''; - sessionStorage.setItem('fankarr_token', fankarrToken); - return true; - } catch (e) { - console.error('[FanKarr] Échec auth :', e); - return false; - } - } - - // --------------------------------------------------------------------------- - // 3. API helpers - // --------------------------------------------------------------------------- - - async function apiGet(path) { - const res = await fetch(`${API_URL}${path}`, { headers: { Authorization: `Bearer ${fankarrToken}` } }); - if (res.status === 401) { await authenticate(); return apiGet(path); } - return res.json(); - } - - async function apiPost(path, body) { - const res = await fetch(`${API_URL}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fankarrToken}` }, - body: JSON.stringify(body), - }); - if (res.status === 401) { await authenticate(); return apiPost(path, body); } - return res.json(); - } - - // --------------------------------------------------------------------------- - // 4. Styles - // --------------------------------------------------------------------------- - - const SECTION_ID = 'fankarr-results-section'; - const MODAL_ID = 'fankarr-modal'; - - const STYLES = ` - #${SECTION_ID} { - padding: 0.5em 0 1em; - } - #${SECTION_ID} .fankarr-section-title { - display: flex !important; - align-items: center !important; - gap: 0.4em !important; - } - #${SECTION_ID} .fankarr-logo { - height: 1em; - width: auto; - display: inline-block; - vertical-align: middle; - filter: brightness(0) saturate(100%) invert(80%) sepia(60%) saturate(400%) hue-rotate(5deg) brightness(105%); - } - #${SECTION_ID} .fankarr-grid { - display: flex; - flex-wrap: wrap; - row-gap: 0.5em; - column-gap: 0; - } - #${SECTION_ID} .fankarr-card { - position: relative; - cursor: pointer; - margin-right: 0.5em; - } - #${SECTION_ID} .fankarr-badge { - position: absolute; - top: 6px; - left: 6px; - z-index: 2; - background: var(--accent-color, var(--accent, #00a4dc)); - color: #fff !important; - font-size: 0.6em; - font-weight: 700; - padding: 2px 6px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.05em; - pointer-events: none; - } - #${SECTION_ID} .fankarr-overlay { - position: absolute; - inset: 0; - background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.6) 50%, rgba(0,0,0,0) 100%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - gap: 0.4em; - opacity: 0; - transition: opacity 0.2s ease; - border-radius: inherit; - padding: 0.6em; - box-sizing: border-box; - z-index: 3; - } - #${SECTION_ID} .fankarr-card:hover .fankarr-overlay { - opacity: 1; - } - #${SECTION_ID} .fankarr-overlay-desc { - color: #fff !important; - font-size: 0.7em; - text-align: center; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; - margin-bottom: 0.3em; - align-self: stretch; - } - #${SECTION_ID} .fankarr-btn { - width: 90%; - font-size: 0.82em; - padding: 0.5em 0.4em; - white-space: nowrap; - display: flex !important; - align-items: center; - justify-content: center; - gap: 0.35em; - color: #fff !important; - background: var(--accent-color, var(--accent, #00a4dc)) !important; - border: none !important; - border-radius: 6px; - } - #${SECTION_ID} .fankarr-card .cardText-first { - overflow: hidden; - white-space: nowrap; - text-overflow: clip; - } - #${SECTION_ID} .fankarr-meta-row { - display: flex; - align-items: center; - justify-content: center; - gap: 0.4em; - font-size: 0.75em; - color: var(--text-color-secondary, #aaa); - margin-top: 1px; - } - #${SECTION_ID} .fankarr-rating { display: flex; align-items: center; gap: 0.2em; } - #${SECTION_ID} .fankarr-rating-star { color: #f5c518; } - - /* ── Backdrop ── */ - #${MODAL_ID}-backdrop { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.7); - z-index: 9998; - display: none; - align-items: flex-end; - justify-content: center; - } - #${MODAL_ID}-backdrop.open { display: flex; } - @media (min-width: 600px) { - #${MODAL_ID}-backdrop { align-items: center; } - } - - /* ── Modale ── */ - #${MODAL_ID} { - background: var(--jf-palette-background-paper, #1a1a1a) !important; - border-radius: 16px 16px 0 0; - width: 100%; - max-width: 500px; - max-height: 85vh; - overflow-y: auto; - padding: 1.5em; - box-sizing: border-box; - font-family: var(--font-family, inherit); - color: #fff !important; - transform: translateY(100%); - transition: transform 0.3s ease; - } - #${MODAL_ID}-backdrop.open #${MODAL_ID} { transform: translateY(0); } - @media (min-width: 600px) { - #${MODAL_ID} { - border-radius: 12px; - max-height: 80vh; - transform: scale(0.95); - opacity: 0; - transition: transform 0.2s ease, opacity 0.2s ease; - } - #${MODAL_ID}-backdrop.open #${MODAL_ID} { transform: scale(1); opacity: 1; } - } - #${MODAL_ID} * { color: #fff !important; box-sizing: border-box; } - #${MODAL_ID} .fankarr-modal-header { display: flex; align-items: center; gap: 1em; margin-bottom: 1.25em; } - #${MODAL_ID} .fankarr-modal-poster { width: 60px; aspect-ratio: 2/3; object-fit: cover; border-radius: 6px; flex-shrink: 0; } - #${MODAL_ID} .fankarr-modal-title { font-size: 1.1em; font-weight: 600; color: #fff !important; } - #${MODAL_ID} .fankarr-modal-close { margin-left: auto; background: none !important; border: none; color: rgba(255,255,255,0.5) !important; font-size: 1.5em; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; } - #${MODAL_ID} .fankarr-modal-close:hover { color: #fff !important; } - #${MODAL_ID} .fankarr-modal-subtitle { font-size: 0.85em; color: rgba(255,255,255,0.6) !important; margin-top: 0.2em; } - #${MODAL_ID} .fankarr-modal-desc { font-size: 0.82em; color: rgba(255,255,255,0.7) !important; line-height: 1.5; margin-bottom: 1em; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } - - /* ── Already-requested banner ── */ - #${MODAL_ID} .fankarr-already-banner { - display: flex; - align-items: center; - gap: 0.5em; - background: rgba(0, 164, 220, 0.15) !important; - border: 1px solid rgba(0, 164, 220, 0.4) !important; - border-radius: 8px; - padding: 0.6em 0.8em; - margin-bottom: 1em; - font-size: 0.82em; - color: rgba(255,255,255,0.85) !important; - } - #${MODAL_ID} .fankarr-already-banner .fankarr-banner-icon { - font-size: 1.1em; - color: var(--accent-color, var(--accent, #00a4dc)) !important; - flex-shrink: 0; - } - - /* ── Season grid ── */ - #${MODAL_ID} .fankarr-select-all { - width: 100%; padding: 0.6em; - border: 2px dashed rgba(255,255,255,0.3) !important; - border-radius: 8px; - background: transparent !important; - color: rgba(255,255,255,0.6) !important; - font-size: 0.85em; font-family: var(--font-family, inherit); - cursor: pointer; margin-bottom: 0.75em; - transition: border-color 0.15s, color 0.15s; - } - #${MODAL_ID} .fankarr-select-all:hover, - #${MODAL_ID} .fankarr-select-all.selected { - border-color: var(--accent-color, var(--accent, #00a4dc)) !important; - color: var(--accent-color, var(--accent, #00a4dc)) !important; - } - #${MODAL_ID} .fankarr-seasons-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 0.5em; margin-bottom: 1.25em; } - #${MODAL_ID} .fankarr-season-btn { - padding: 0.6em 0.25em; - border: 2px solid rgba(255,255,255,0.25) !important; - border-radius: 8px; - background: transparent !important; - color: #fff !important; - font-size: 0.8em; font-family: var(--font-family, inherit); - cursor: pointer; text-align: center; - transition: border-color 0.15s, background 0.15s, opacity 0.15s; - } - #${MODAL_ID} .fankarr-season-btn:hover:not(:disabled) { border-color: var(--accent-color, var(--accent, #00a4dc)) !important; } - #${MODAL_ID} .fankarr-season-btn.selected { - border-color: var(--accent-color, var(--accent, #00a4dc)) !important; - background: var(--accent-color, var(--accent, #00a4dc)) !important; - color: #fff !important; - } - /* Already-requested seasons: shown as selected + locked */ - #${MODAL_ID} .fankarr-season-btn.already { - border-color: var(--accent-color, var(--accent, #00a4dc)) !important; - background: var(--accent-color, var(--accent, #00a4dc)) !important; - color: #fff !important; - cursor: default; - opacity: 0.55; - position: relative; - } - #${MODAL_ID} .fankarr-season-btn.already::after { - content: ' ✓'; - font-size: 0.85em; - } - - #${MODAL_ID} .fankarr-modal-actions { display: flex; gap: 0.75em; margin-top: 1em; } - #${MODAL_ID} .fankarr-modal-cancel { - flex: 1; - background: rgba(255,255,255,0.1) !important; - color: #fff !important; - border: 1px solid rgba(255,255,255,0.2) !important; - } - #${MODAL_ID} .fankarr-modal-submit { - flex: 2; - background: var(--accent-color, var(--accent, #00a4dc)) !important; - color: #fff !important; - border: none !important; - } - #${MODAL_ID} .fankarr-modal-submit:disabled { - opacity: 0.4; - cursor: default; - } - #${MODAL_ID} .fankarr-modal-loading { text-align: center; padding: 2em 0; color: rgba(255,255,255,0.6) !important; font-size: 0.9em; } - #${MODAL_ID} .fankarr-modal-success { text-align: center; padding: 2em 1em; } - #${MODAL_ID} .fankarr-modal-success-icon { font-size: 2.5em; margin-bottom: 0.4em; color: var(--accent-color, var(--accent, #00a4dc)) !important; } - #${MODAL_ID} .fankarr-modal-success-title { font-weight: 600; margin-bottom: 0.4em; color: #fff !important; } - #${MODAL_ID} .fankarr-modal-success-sub { color: rgba(255,255,255,0.6) !important; font-size: 0.9em; margin-bottom: 1.5em; } - #${MODAL_ID} .fankarr-modal-success button { - background: var(--accent-color, var(--accent, #00a4dc)) !important; - color: #fff !important; - border: none !important; - } - `; - - function injectStyles() { - if (document.getElementById('fankarr-styles')) return; - const style = document.createElement('style'); - style.id = 'fankarr-styles'; - style.textContent = STYLES; - document.head.appendChild(style); - } - - // --------------------------------------------------------------------------- - // 5. Modale - // --------------------------------------------------------------------------- - - function createModal() { - if (document.getElementById(`${MODAL_ID}-backdrop`)) return; - const backdrop = document.createElement('div'); - backdrop.id = `${MODAL_ID}-backdrop`; - backdrop.innerHTML = `
`; - document.body.appendChild(backdrop); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); }); - } - - function openModal() { - const backdrop = document.getElementById(`${MODAL_ID}-backdrop`); - if (backdrop) { backdrop.classList.add('open'); document.body.style.overflow = 'hidden'; } - } - - function closeModal() { - const backdrop = document.getElementById(`${MODAL_ID}-backdrop`); - if (backdrop) { backdrop.classList.remove('open'); document.body.style.overflow = ''; } - } - - async function showRequestModal(item, alreadyRequestedSeasons = []) { - createModal(); - const modal = document.getElementById(MODAL_ID); - const posterHtml = item.image - ? `${escapeHtml(item.title)}` - : ''; - - // alreadyRequestedSeasons: array of season numbers already submitted. - // Empty array = whole series already requested. - const wholeSeriesAlreadyRequested = Array.isArray(alreadyRequestedSeasons) && alreadyRequestedSeasons.length === 0 && item._wasRequested; - const alreadySet = new Set(alreadyRequestedSeasons); - - modal.innerHTML = ` -
- ${posterHtml} -
${escapeHtml(item.title)}
- -
-
Chargement des saisons…
- `; - modal.querySelector('.fankarr-modal-close').addEventListener('click', closeModal); - openModal(); - - let seasons = []; - let serieDesc = item.description || ''; - try { - const data = await apiGet(`/api/v1/series/${item.id}`); - seasons = data.seasons || []; - if (data.description) serieDesc = data.description; - } catch (e) { - console.error('[FanKarr] Erreur chargement saisons :', e); + // Demandes actives de cet utilisateur, indexées par serieId + const activeRequests = getRequestsForUser(req.user!.id) + .filter(r => r.status !== 'rejected' && r.status !== 'completed') + const requestMap = new Map() + for (const r of activeRequests) { + const me = r.requesters.find(rq => rq.userId === req.user!.id) + if (!me) continue + requestMap.set(r.serieId, { + id : r.id, + status : r.status, + seasons : me.seasons, + episodes: me.episodes ?? [], + }) } - // Seasons selectable = only those NOT already requested - const selectableSeasons = seasons.filter(s => !alreadySet.has(s.season_number)); + const results = all + .filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q)) + .slice(0, 50) + .map(s => ({ + id : s.id, + title : s.title, + original_title: s.original_title ?? null, + image : s.poster_image ?? null, + available : availableSet.has(s.id), + year : s.year ?? null, + rating : s.rating?.value ?? null, + description : s.plot ?? null, + request : requestMap.get(s.id) ?? null, + })) - // New selections (from the selectable ones only) - const selectedSeasons = new Set(); - - function renderModal() { - const allSelectableSelected = selectableSeasons.length > 0 && selectableSeasons.every(s => selectedSeasons.has(s.season_number)); - const hasNewSelection = selectedSeasons.size > 0; - - const isAddMode = item._wasRequested; // already had a request - - const bannerHtml = isAddMode ? ` -
- info - ${ - wholeSeriesAlreadyRequested - ? 'Toute la série a déjà été demandée.' - : `Saison${alreadyRequestedSeasons.length > 1 ? 's' : ''} ${alreadyRequestedSeasons.join(', ')} déjà demandée${alreadyRequestedSeasons.length > 1 ? 's' : ''}. Sélectionne les saisons à ajouter.` - } -
- ` : ''; - - // "Select all remaining" button — only show if there are selectable seasons - const selectAllHtml = selectableSeasons.length > 0 ? ` - - ` : ''; - - // Count hint - let countHint = ''; - if (wholeSeriesAlreadyRequested) { - countHint = ''; - } else if (selectedSeasons.size === 0 && selectableSeasons.length > 0) { - countHint = isAddMode ? 'Sélectionne au moins une saison à ajouter' : 'Toute la série sera demandée'; - } else if (selectedSeasons.size > 0) { - countHint = `${selectedSeasons.size} saison${selectedSeasons.size > 1 ? 's' : ''} sélectionnée${selectedSeasons.size > 1 ? 's' : ''}`; - } - - modal.innerHTML = ` -
- ${posterHtml} -
-
${escapeHtml(item.title)}
-
${seasons.length} saison${seasons.length > 1 ? 's' : ''}${item.year ? ' · ' + item.year : ''}${item.rating ? ' · ★ ' + Number(item.rating).toFixed(1) : ''}
-
- -
- ${serieDesc ? `
${escapeHtml(serieDesc)}
` : ''} - ${bannerHtml} - ${selectAllHtml} -
- ${seasons.map(s => { - const isAlready = alreadySet.has(s.season_number); - const isSelected = selectedSeasons.has(s.season_number); - let cls = 'fankarr-season-btn'; - if (isAlready) cls += ' already selected'; - else if (isSelected) cls += ' selected'; - return ` - - `; - }).join('')} -
- ${countHint ? `
${countHint}
` : ''} -
- - -
- `; - - modal.querySelector('.fankarr-modal-close').addEventListener('click', closeModal); - modal.querySelector('.fankarr-modal-cancel').addEventListener('click', closeModal); - - const selectAllBtn = modal.querySelector('.fankarr-select-all'); - if (selectAllBtn) { - selectAllBtn.addEventListener('click', () => { - if (allSelectableSelected) { - selectedSeasons.clear(); - } else { - selectableSeasons.forEach(s => selectedSeasons.add(s.season_number)); - } - renderModal(); - }); - } - - modal.querySelectorAll('.fankarr-season-btn:not([disabled])').forEach(btn => { - btn.addEventListener('click', () => { - const n = parseInt(btn.dataset.season); - if (selectedSeasons.has(n)) { selectedSeasons.delete(n); } else { selectedSeasons.add(n); } - renderModal(); - }); - }); - - const submitBtn = modal.querySelector('.fankarr-modal-submit'); - if (submitBtn && !submitBtn.disabled) { - submitBtn.addEventListener('click', async () => { - submitBtn.disabled = true; - submitBtn.textContent = '…'; - try { - // Send only the newly selected seasons — the API merges (upsert) - const seasonsToSend = selectedSeasons.size > 0 - ? [...selectedSeasons].sort((a, b) => a - b) - : []; - - await apiPost('/api/v1/requests', { - serieId: item.id, - serieName: item.title, - seasons: seasonsToSend, - }); - - const newlyRequested = [...selectedSeasons].sort((a, b) => a - b); - let seasonsLabel; - if (newlyRequested.length === 0) { - seasonsLabel = 'Toute la série demandée !'; - } else if (isAddMode) { - seasonsLabel = `Saison${newlyRequested.length > 1 ? 's' : ''} ${newlyRequested.join(', ')} ajoutée${newlyRequested.length > 1 ? 's' : ''} !`; - } else { - seasonsLabel = `Saison${newlyRequested.length > 1 ? 's' : ''} ${newlyRequested.join(', ')} demandée${newlyRequested.length > 1 ? 's' : ''} !`; - } - - modal.innerHTML = ` -
-
✓
-
${escapeHtml(item.title)}
-
${seasonsLabel}
- -
- `; - modal.querySelector('button').addEventListener('click', closeModal); - - // Update card button label - updateCardButton(item.id, [...alreadySet, ...newlyRequested], seasons.length); - } catch (e) { - console.error('[FanKarr] Erreur demande :', e); - submitBtn.disabled = false; - submitBtn.textContent = 'Réessayer'; - } - }); - } - } - - renderModal(); + res.json(results) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' }) } +}) - // Update the card button after a successful request - function updateCardButton(serieId, allRequestedSeasons, totalSeasons) { - const card = document.querySelector(`[data-fankarr-id="${serieId}"]`); - if (!card) return; - const btn = card.querySelector('.fankarr-btn'); - if (!btn) return; +// ── GET /api/v1/series/:id ──────────────────────────────────── +// Détail d'une série : saisons + épisodes (proxy FanKai) +router.get('/v1/series/:id', requireApiToken, async (req, res) => { + const id = Number(req.params.id) + if (isNaN(id)) { res.status(400).json({ error: 'id invalide' }); return } + try { + const [serieRaw, seasonsData] = await Promise.all([ + fankaiGet(`/series/${id}`), + fankaiGet(`/series/${id}/seasons`), + ]) + const serie = normalizeSerie(serieRaw) + const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) - const allDone = allRequestedSeasons.length === 0 || allRequestedSeasons.length >= totalSeasons; - if (allDone) { - btn.innerHTML = 'check Demandé'; - btn.classList.add('button-submit'); - } else { - btn.innerHTML = 'add Demander plus'; - btn.classList.remove('button-submit'); - } - } - - // --------------------------------------------------------------------------- - // 6. UI — section et cards - // --------------------------------------------------------------------------- - - function getOrCreateSection(container) { - let section = document.getElementById(SECTION_ID); - if (!section) { - section = document.createElement('div'); - section.id = SECTION_ID; - section.innerHTML = ` -

- Découvrir sur FanKaï - -

-
- `; - container.appendChild(section); - - // Reposition before Episodes section if it appears later - setTimeout(() => { - const epSection = Array.from(container.querySelectorAll('.verticalSection')) - .find(s => s.querySelector('.sectionTitle')?.textContent?.toLowerCase().includes('épisode')); - if (epSection && section.parentNode === container) { - container.insertBefore(section, epSection); + const seasonsWithEpisodes = await Promise.all( + seasons.map(async (season: any) => { + const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) + const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])) + .map((ep: any) => ({ + id : ep.id, + episode_number: ep.episode_number, + title : ep.title ?? null, + image : normalizeEpisode(ep).thumb_image ?? null, + })) + return { + ...normalizeSeason(season), + season_number: season.season_number, + episodes, } - }, 1000); + }) + ) + + res.json({ + id : serie.id, + title : serie.title, + original_title: serie.original_title ?? null, + image : serie.poster_image ?? null, + seasons : seasonsWithEpisodes, + }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// ── POST /api/v1/requests ───────────────────────────────────── +// Créer ou mettre à jour une demande +router.post('/v1/requests', requireApiToken, (req, res) => { + const { serieId, serieName, seasons, episodes } = req.body + if (!serieId || !serieName) { + res.status(400).json({ error: 'serieId et serieName requis' }); return + } + try { + const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : [] + const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : [] + + let request = upsertRequest( + req.user!.id, + Number(serieId), + String(serieName), + submittedSeasons, + submittedEpisodes, + ) + + // Auto-approbation + téléchargement si l'utilisateur est autorisé + const { requestAutoDownloadUsers } = readSettings() + const userId = req.user!.id + const allowed = requestAutoDownloadUsers === 'all' + || (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId)) + + if (allowed) { + const isNew = request.status === 'pending' + if (isNew) request = approveRequest(request.id) + const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes } + autoDownloadRequest(request, dlOverride).catch(err => + logger.warn('public-api', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`) + ) } - return section; + + res.json(request) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) } +}) - function renderResults(section, results) { - const grid = section.querySelector('.fankarr-grid'); - if (!grid) return; - grid.innerHTML = ''; +// ── GET /api/v1/requests ────────────────────────────────────── +// Demandes de l'utilisateur authentifié +router.get('/v1/requests', requireApiToken, (req, res) => { + res.json(getRequestsForUser(req.user!.id)) +}) - results.forEach(item => { - // item.request: null → never requested - // item.request.seasons: [] → whole series requested - // item.request.seasons: [1,2] → specific seasons requested - const req = item.request; - const wasRequested = req != null; - const requestedSeasons = wasRequested ? (req.seasons || []) : []; - const wholeSeriesRequested = wasRequested && requestedSeasons.length === 0; - - // Enrich item with request state for modal - item._wasRequested = wasRequested; - item._requestedSeasons = requestedSeasons; - - const poster = item.image || (item.posterPath ? `https://image.tmdb.org/t/p/w200${item.posterPath}` : null); - const year = item.year || ''; - const rating = item.rating ? Number(item.rating).toFixed(1) : null; - const desc = item.description || ''; - - // Button label - let btnIcon, btnLabel, btnClass; - if (wholeSeriesRequested) { - btnIcon = 'check'; btnLabel = 'Demandé'; btnClass = ' button-submit'; - } else if (wasRequested) { - btnIcon = 'add'; btnLabel = 'Demander plus'; btnClass = ''; - } else { - btnIcon = 'download'; btnLabel = 'Demander'; btnClass = ''; - } - - const card = document.createElement('div'); - card.className = 'fankarr-card card overflowPortraitCard card-withuserdata'; - card.dataset.fankarrId = item.id; - - card.innerHTML = ` -
-
-
- ${poster - ? `
` - : `
` - } - SÉRIE -
- ${desc ? `
${escapeHtml(desc)}
` : ''} - -
-
-
${escapeHtml(item.title)}
-
- ${year ? `${year}` : ''} - ${year && rating ? `·` : ''} - ${rating ? `★${rating}` : ''} -
-
- `; - - // Always clickable unless whole series already requested - if (!wholeSeriesRequested) { - card.querySelector('.fankarr-btn').addEventListener('click', (e) => { - e.stopPropagation(); - showRequestModal(item, item._requestedSeasons); - }); - } - - grid.appendChild(card); - }); - } - - // --------------------------------------------------------------------------- - // 7. Search hook - // --------------------------------------------------------------------------- - - let searchTimeout = null; - let lastQuery = ''; - - // Wait for .searchResults to appear in the DOM (max ~3s) - function waitForSearchResults(maxWait = 3000) { - return new Promise(resolve => { - const existing = document.querySelector('.searchResults'); - if (existing) return resolve(existing); - - const interval = 100; - let elapsed = 0; - const timer = setInterval(() => { - const el = document.querySelector('.searchResults'); - elapsed += interval; - if (el || elapsed >= maxWait) { - clearInterval(timer); - resolve(el || null); - } - }, interval); - }); - } - - async function onSearch(query) { - if (!query || query.length < 2) { - const existing = document.getElementById(SECTION_ID); - if (existing) existing.remove(); - return; - } - if (query === lastQuery) return; - lastQuery = query; - - try { - const [searchData, realContainer] = await Promise.all([ - apiGet(`/api/v1/series/search?q=${encodeURIComponent(query)}`), - waitForSearchResults(), - ]); - - if (!realContainer) { - console.warn('[FanKarr] .searchResults introuvable, injection annulée.'); - return; - } - - const results = searchData.results || searchData || []; - if (!results || results.length === 0) { - const existing = document.getElementById(SECTION_ID); - if (existing) existing.remove(); - return; - } - - const section = getOrCreateSection(realContainer); - renderResults(section, results); - } catch (e) { - console.error('[FanKarr] Erreur recherche :', e); - const existing = document.getElementById(SECTION_ID); - if (existing) existing.remove(); - } - } - - // --------------------------------------------------------------------------- - // 8. Observer - // --------------------------------------------------------------------------- - - function findSearchInput() { return document.querySelector('#searchTextInput'); } - - function isSearchPage() { - return window.location.hash.includes('search') || !!document.querySelector('#searchTextInput'); - } - - let inputListenerAttached = false; - - function attachInputListener() { - if (inputListenerAttached) return; - const input = findSearchInput(); - if (!input) return; - inputListenerAttached = true; - input.addEventListener('input', () => { - clearTimeout(searchTimeout); - const query = input.value.trim(); - searchTimeout = setTimeout(() => onSearch(query), 400); - }); - if (input.value) { - onSearch(input.value.trim()); - } - } - - const observer = new MutationObserver(() => { - if (isSearchPage()) { injectStyles(); attachInputListener(); } - else { inputListenerAttached = false; lastQuery = ''; closeModal(); } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - // --------------------------------------------------------------------------- - // Init - // --------------------------------------------------------------------------- - - injectStyles(); - createModal(); - if (!fankarrToken) await authenticate(); - if (isSearchPage()) attachInputListener(); - console.info('[FanKarr] Plugin chargé ✓'); - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } -})(); \ No newline at end of file +export default router