fix: button contrast and font size on card overlay
This commit is contained in:
1 parent
ec1d1705e7
commit
bd5b16e17c
1 file changed
+770
-189
+770
-189
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user