diff --git a/server/routes/public-api.ts b/server/routes/public-api.ts index 005b138..67e5f30 100644 --- a/server/routes/public-api.ts +++ b/server/routes/public-api.ts @@ -1,209 +1,848 @@ /** - * API publique v1 — pour le plugin Jellyfin et intégrations tierces - * Auth : Bearer {apiToken} dans le header Authorization + * FanKarr Jellyfin Plugin + * ======================== + * Injecte une section "Découvrir sur FanKaï" dans la page de recherche Jellyfin. */ -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' +(async function FanKarr() { + 'use strict'; -const router = Router() + const FANKAI_LOGO = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNTAgMTUwIj48c3R5bGU+LmNscy0ye2ZpbGw6Y3VycmVudENvbG9yfTwvc3R5bGU+PGc+PHBhdGggY2xhc3M9ImNscy0yIiBkPSJtMTEzLjg1LDEyLjhjLTEuOTQsMy41Ny0zLjgsNy4wOS01Ljc2LDEwLjU1LTMuNiw2LjM1LTcuMzEsMTIuNjUtMTAuOSwxOS4wMS0xLjY5LDMtMy4yMiw2LjA4LTQuNDQsOS40OSwzLjI1LTIuNDYsNi40NC01LjAzLDkuNzgtNy4zNiwyLjkyLTIuMDQsNS45NS0zLjk2LDkuMDctNS42Nyw4LjU5LTQuNzIsMTkuMjktMS43OCwyNC40LDYuNTQuNDIuNjgsMS4zLDEuMTYsMS41MiwxLjg3LjI2LjgzLjA1LDIuNjIsMCwyLjYyLTMuOTQuMTMtMy40NSw1Ljc5LTcuNTksNS44OS0zLjQ2LjA4LTYuOSwxLjI1LTEwLjM3LDEuMzctNi43OC4yNS0xMy40OC42NC0xOS4zMiw0LjU3LTEuOTEsMS4yOS0zLjUsMy4wNS00Ljk3LDQuMzYuMjEuODYuMTksMS4xMy4zLDEuMiw0LjQyLDIuNyw4Ljc5LDUuNDksMTMuMzEsOCwyLjExLDEuMTcsMy42NiwzLjc3LDIuODIsNi42My0xLjE4LDMuOTctMi42NCw3Ljg2LTQuMDQsMTEuNzYtLjQ4LDEuMzMtMS4xMSwyLjYxLTEuODgsNC40MSw1Ljg3LDIuMTQsMTEuNjMsNC4yNiwxNy40MSw2LjM1LDMuNDMsMS4yNCw2Ljg2LDIuNDcsMTAuMzEsMy42NSw3LjM4LDIuNTIsOS4xNiw5LjU0LDExLjUyLDE1LjUzLDEuMDQsMi42NS0uNzUsNi43NS0yLjExLDkuODEtLjQ2LDEuMDQtMy41MywxLjIyLTUuNDIsMS4yNi0xLjc4LjAzLTMuNTctLjY0LTQuNDYtLjgzLTUuOTctOC40LTExLjU0LTE1Ljk4LTIzLjI4LTE0LjcuMS0uMzIuMjEtLjY1LjMxLS45NywyLjMyLS41Miw0LjY0LTEuMDUsNi45Ny0xLjU3LjAzLS4yMi4wNi0uNDUuMDktLjY3LTIuOTctMS4xOC01Ljk0LTIuMzctOC45Mi0zLjUyLTEuMzMtLjUyLTIuNy0uOTMtNC4wMy0xLjQ1LTQuODMtMS45LTQuNzktMS44OS04LjI2LDIuMjctNC4zMSw1LjE3LTEwLjIyLDguMTMtMTYuMywxMS4wMS42OS0uODQsMS4zMS0xLjc1LDIuMS0yLjQ4Ljc2LS43MSwxLjY5LTEuMjIsMi4yNi0yLjI5LTQuOSwxLjg3LTkuODEsMy43NC0xNC43MSw1LjYxLS4xMy0uMjItLjI2LS40NS0uMzktLjY3Ljg2LS42MiwxLjcyLTEuMjQsMi41OC0xLjg1LS4wNC0uMTctLjA4LS4zNC0uMTItLjUxLTIuOTQuNjUtNS45MywxLjExLTguOCwyLTQuMTgsMS4yOS04LjI3LDIuODctMTIuMzgsNC4zOC0yLjU5Ljk1LTUuMTIsMS41MS03LjE5LS4xNCwxMS44NC05LjQsMjUuMTYtMTcuMiwzNi41OS0yOC45My00LjIxLTMuNjUtOC4xLTcuMDEtMTEuOTgtMTAuMzguMTUtLjQzLjMtLjg3LjQ1LTEuMywxLjYxLS4wOSwzLjMtLjU1LDQuODEtLjIxLDQuMTQuOTIsOC4yMSwyLjE4LDEyLjY2LDMuNCwzLjU4LTYuNiw3LjEtMTMuMjksNy4zLTIwLjU4LTYuNjgtMi42NS05Ljc5LDQuMTMtMTUuNyw1Ljc2LDIuNTUtMi45Miw0LjU0LTUuMTksNi41Mi03LjQ2LS4yLS4xNy0uMzktLjMzLS41OS0uNS01LjU4LDQuMjgtMTYuODksMTYuMDYtMjQuMTYsMjkuMDctLjE5LS4yOS0uMzctLjU5LS41NS0uODctLjgxLDEuNDYtMS41NywyLjgzLTIuMzMsNC4yMWwtMS4wNC0uMzVjMS41OC01LjcxLDIuODYtMTEuNTMsNC44MS0xNy4xMSwyLjY3LTcuNjcsNi4zMi0xNS4wMiw4LjcxLTIyLjc2LDQuODctMTUuOCwxMy43NS0yOS42NywyMC43LTQ0LjQ1LjY1LTEuMzgsMS4yOC0yLjc3LDEuODgtNC4xNywxLjU4LTMuNjgsMy45Ny03LjM3LS40Ny0xMS4xMy4zLS4xNi41OS0uMzMuODktLjQ5LDcuMTMsNC4wOCwxNC4yNSw4LjE1LDIyLjM4LDEyLjhaIi8+PHBhdGggY2xhc3M9ImNscy0yIiBkPSJtNy4wMyw3MS43MmMuMjUtLjQ4LjM5LTEuMjEuNzctMS4zOCwxLjEtLjQ5LDIuNjItMS4zOCwzLjQxLS45OCwyLjU4LDEuMjgsNC44LjM4LDcuMDYtLjQ4LDIuNzYtMS4wNCw1LjU3LTIuMDUsOC4xNC0zLjQ3LDEuMjItLjY3LDIuNjMtMi4xOSwyLjc3LTMuNDYsMS4wMy05LjY1LDMuNi0xOC44Myw3LjU0LTI3LjY3LjI0LS41My4zLTEuMTQtLjE3LTIuMDMtMS4wMSwxLjYzLTEuNzgsMy40OS0zLjA4LDQuODUtMi4wOCwyLjIxLTQuNDIsNC4yLTYuODIsNi4wNy0xLjM5LDEuMDktMy4wMiwyLjA1LTQuNywyLjU0LTEuMzEuMzktMy4yNi41MS00LjI0LS4yLTQuMzItMy4xMy04LjM4LTYuNi0xMi43MS0xMC4wOSwyLjAzLTIuNDgsMy43My00LjIsNy4xOC0zLjUsMS43NC4zNiw0LjA0LS40Myw1LjY3LTEuNCw1LjQzLTMuMjEsMTAuNjUtNi43OSwxNi4wMS0xMC4xMyw1LjU4LTMuNDgsMTEuNDUtMy40NiwxNy4xMy0uNTMsNC41LDIuMzIsOC43Myw1LjE5LDEzLjAzLDcuOSwxLjQ3LjkzLDIuMzUsMi4yMywxLjM0LDQuMTEtMi4wOCwzLjg4LTQuMDEsNy44NS02LjIyLDExLjY2LTEuOTUsMy4zNy00LjIsNi41Ni02LjQ3LDEwLjA2LDIuMDQuOTYsMy43OCwxLjY3LDUuNCwyLjU4LDIuMjUsMS4yNywzLjI1LDMuMjQsMi45Nyw1Ljg3LS4zMywzLjE1LTEuNTksNS42NC00LjY4LDYuODQtLjg0LjMzLTEuODMuNzItMi42NS41Ni01LjgtMS4xLTkuNzksMi45My0xNC40Myw1LjA0LTEuMi41NC0yLjE5LDEuODMtMi45NSwyLjk5LTMuMyw1LjA4LTguODMsNi43LTEzLjgzLDkuMTctLjUzLjI2LTEuMjIuNzQtMS4zNCwxLjI0LTEuNzYsNy44Mi0zLjQ1LDE1LjY1LTQuNTgsMjQuMDksNC4xLTIuMzIsOC4xOS00LjY1LDEyLjI5LTYuOTcuMTIuMTUuMjMuMzEuMzUuNDYtMS4wNSwxLjE3LTIuMSwyLjM0LTMuMTUsMy41MmwuNS42NmM1LjczLTIuNjEsMTAuOTctNS45OCwxNS4yNC0xMC44Ny4yOC4yMi41Ny40My44NS42NS0xLjgzLDMuMzktMy43OCw2LjcyLTUuNDQsMTAuMi0uOTUsMS45OS0xLjM4LDQuMjEtMi4xMyw2LjMtLjQ4LDEuMzMtLjg0LDIuODItMS43MSwzLjg2LTIuNjEsMy4xMy01LjQ2LDYuMDYtOC4xOSw5LjA5LS40NS41LS43NiwxLjEyLTEuMTQsMS42OC0uNjYuOTktMS4yMSwyLjA5LTIuMDIsMi45NC0uMzkuNDEtMS41OC43MS0xLjg1LjQ2LTIuMjYtMi4xNC01LjUsMi4yNS03Ljc4LTEuNTItMS45LTMuMTMtNS4zNS01LjMzLTcuMjMtOC40Ny0yLjM3LTMuOTUtNS43NS03Ljg0LTUuMTQtMTIuODUuNTEtNC4xNywyLjE2LTguMTgsMi44OS0xMi4zNCwxLjQzLTguMTEsMi42LTE2LjI3LDMuODItMjQuNDIuMTQtLjk1LS4xMS0xLjk1LS4xOS0yLjkzLjE0LjA5LjI5LjE3LjQzLjI2WiIvPjwvZz48L3N2Zz4K'; -// ── Middleware auth API token ───────────────────────────────── + // --------------------------------------------------------------------------- + // 1. Config + // --------------------------------------------------------------------------- -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() -} + let API_URL = ''; + let fankarrToken = sessionStorage.getItem('fankarr_token') || ''; -// ── 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 [apiData, availableIds] = await Promise.all([ - fankaiGet('/series'), - readAvailable(), - ]) - const availableSet = new Set(availableIds) - const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) + 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; + } - // 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 ?? [], - }) + 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, + #${MODAL_ID} .fankarr-modal-submit { + padding: 0.7em 1em; + border-radius: 8px; + font-size: 0.9em; + font-family: var(--font-family, inherit); + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; + outline: none !important; + box-shadow: none !important; + } + #${MODAL_ID} .fankarr-modal-cancel:hover, + #${MODAL_ID} .fankarr-modal-cancel:focus, + #${MODAL_ID} .fankarr-modal-submit:hover, + #${MODAL_ID} .fankarr-modal-submit:focus { + background: inherit; + border: inherit; + color: inherit; + opacity: 0.85; + } + #${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.15) !important; + } + #${MODAL_ID} .fankarr-modal-cancel:hover, + #${MODAL_ID} .fankarr-modal-cancel:focus { + background: rgba(255,255,255,0.15) !important; + border: 1px solid rgba(255,255,255,0.15) !important; + color: #fff !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:hover, + #${MODAL_ID} .fankarr-modal-submit:focus { + background: var(--accent-color, var(--accent, #00a4dc)) !important; + color: #fff !important; + border: none !important; + opacity: 0.85; + } + #${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; + border-radius: 8px; + padding: 0.7em 1em; + font-size: 0.9em; + font-weight: 600; + cursor: pointer; + } + #${MODAL_ID} .fankarr-modal-success button:hover, + #${MODAL_ID} .fankarr-modal-success button:focus { + background: var(--accent-color, var(--accent, #00a4dc)) !important; + color: #fff !important; + border: none !important; + opacity: 0.85; + outline: none !important; + box-shadow: 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); } - 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, - })) + // Seasons selectable = only those NOT already requested + const selectableSeasons = seasons.filter(s => !alreadySet.has(s.season_number)); - res.json(results) - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' }) + // 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 + const allRequested = [...alreadySet, ...newlyRequested]; + updateCardButton(item.id, allRequested, seasons.length); + + // Update item state so reopening the modal reflects the new request + item._wasRequested = true; + item._requestedSeasons = allRequested.sort((a, b) => a - b); + item.request = { + ...(item.request || {}), + seasons: item._requestedSeasons, + status: item.request?.status || 'pending', + }; + } catch (e) { + console.error('[FanKarr] Erreur demande :', e); + submitBtn.disabled = false; + submitBtn.textContent = 'Réessayer'; + } + }); + } + } + + renderModal(); } -}) -// ── 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 ?? []) + // 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; - 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, + 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); } - }) - ) - - 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}`) - ) + }, 1000); } - - res.json(request) - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + return section; } -}) -// ── GET /api/v1/requests ────────────────────────────────────── -// Demandes de l'utilisateur authentifié -router.get('/v1/requests', requireApiToken, (req, res) => { - res.json(getRequestsForUser(req.user!.id)) -}) + function renderResults(section, results) { + const grid = section.querySelector('.fankarr-grid'); + if (!grid) return; + grid.innerHTML = ''; -export default router + 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