fix: button contrast and font size on card overlay

This commit is contained in:
MasutaYunikon committed 2026-05-23 19:01:03 +02:00
1 parent ec1d1705e7
commit bd5b16e17c
1 file changed
+770 -189
+770 -189
View File
@@ -1,209 +1,790 @@
/**
* 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 <token>)' }); 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<number>(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<number, { id: string; status: string; seasons: number[]; episodes: number[] }>()
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 {
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 = `<div id="${MODAL_ID}"></div>`;
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
? `<img class="fankarr-modal-poster" src="${escapeHtml(item.image)}" alt="${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 = `
<div class="fankarr-modal-header">
${posterHtml}
<div><div class="fankarr-modal-title">${escapeHtml(item.title)}</div></div>
<button class="fankarr-modal-close" aria-label="Fermer">×</button>
</div>
<div class="fankarr-modal-loading">Chargement des saisons…</div>
`;
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 ? `
<div class="fankarr-already-banner">
<span class="material-icons fankarr-banner-icon">info</span>
<span>${
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.`
}</span>
</div>
` : '';
// "Select all remaining" button — only show if there are selectable seasons
const selectAllHtml = selectableSeasons.length > 0 ? `
<button class="fankarr-select-all${allSelectableSelected ? ' selected' : ''}">
${allSelectableSelected ? '✓ Toutes les saisons restantes sélectionnées' : 'Sélectionner toutes les saisons restantes'}
</button>
` : '';
// 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 = `
<div class="fankarr-modal-header">
${posterHtml}
<div style="flex:1;min-width:0;">
<div class="fankarr-modal-title">${escapeHtml(item.title)}</div>
<div class="fankarr-modal-subtitle">${seasons.length} saison${seasons.length > 1 ? 's' : ''}${item.year ? ' · ' + item.year : ''}${item.rating ? ' · ★ ' + Number(item.rating).toFixed(1) : ''}</div>
</div>
<button class="fankarr-modal-close" aria-label="Fermer">×</button>
</div>
${serieDesc ? `<div class="fankarr-modal-desc">${escapeHtml(serieDesc)}</div>` : ''}
${bannerHtml}
${selectAllHtml}
<div class="fankarr-seasons-grid">
${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 `
<button class="${cls}" data-season="${s.season_number}" ${isAlready ? 'disabled title="Déjà demandée"' : ''}>
Saison ${s.season_number}
</button>
`;
}).join('')}
</div>
${countHint ? `<div style="font-size:0.8em;color:rgba(255,255,255,0.5);text-align:center;margin-bottom:0.5em;">${countHint}</div>` : ''}
<div class="fankarr-modal-actions">
<button class="fankarr-modal-cancel emby-button raised">Annuler</button>
<button class="fankarr-modal-submit emby-button raised button-submit" ${(isAddMode && !hasNewSelection) || wholeSeriesAlreadyRequested ? 'disabled' : ''}>
<span class="material-icons" style="font-size:1em">${isAddMode ? 'add' : 'download'}</span>
${isAddMode ? 'Demander plus' : 'Demander'}
</button>
</div>
`;
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 = `
<div class="fankarr-modal-success">
<div class="fankarr-modal-success-icon">✓</div>
<div class="fankarr-modal-success-title">${escapeHtml(item.title)}</div>
<div class="fankarr-modal-success-sub">${seasonsLabel}</div>
<button class="emby-button raised button-submit" style="width:100%;">Fermer</button>
</div>
`;
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();
}
})
// ── 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 = '<span class="material-icons" style="font-size:1em">check</span> Demandé';
btn.classList.add('button-submit');
} else {
btn.innerHTML = '<span class="material-icons" style="font-size:1em">add</span> 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 = `
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right fankarr-section-title">
Découvrir sur FanKaï
<img class="fankarr-logo" src="${FANKAI_LOGO}" alt="FanKaï" />
</h2>
<div class="fankarr-grid padded-left"></div>
`;
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 = `
<div class="cardBox cardBox-bottompadded">
<div class="cardScalable">
<div class="cardPadder cardPadder-overflowPortrait"></div>
${poster
? `<div class="cardImageContainer coveredImage cardContent" style="background-image:url('${escapeHtml(poster)}');"></div>`
: `<div class="cardImageContainer defaultCardBackground defaultCardBackground1 cardContent"><span class="cardImageIcon material-icons tv" aria-hidden="true"></span></div>`
}
<span class="fankarr-badge">SÉRIE</span>
<div class="fankarr-overlay">
${desc ? `<div class="fankarr-overlay-desc">${escapeHtml(desc)}</div>` : ''}
<button class="fankarr-btn emby-button raised${btnClass}">
<span class="material-icons" style="font-size:1em">${btnIcon}</span>
${btnLabel}
</button>
</div>
</div>
<div class="cardText cardTextCentered cardText-first"><bdi>${escapeHtml(item.title)}</bdi></div>
<div class="fankarr-meta-row">
${year ? `<span>${year}</span>` : ''}
${year && rating ? `<span>·</span>` : ''}
${rating ? `<span class="fankarr-rating"><span class="fankarr-rating-star">★</span>${rating}</span>` : ''}
</div>
</div>
`;
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();