From 82ee05f9025437f64a7129a6e3d2946516354656 Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 10 Jun 2026 23:18:28 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20xREL=20sauber=20=E2=80=93=201=20Cache-Ke?= =?UTF-8?q?y/Film,=20Modal=20sofort,=20xREL=20async,=20Hindi-Filter,=20max?= =?UTF-8?q?5/=E2=89=A41080p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/media/routes.js | 349 +++++++++++++----------------- frontend/src/tools/media.jsx | 13 +- 2 files changed, 151 insertions(+), 211 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 792d75e..a4c5c0f 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -5,23 +5,25 @@ const { authenticate, requireAdmin } = require('../../middleware/auth'); const TMDB_BASE = 'https://api.themoviedb.org/3'; const XREL_BASE = 'https://api.xrel.to/v2'; - const THIS_YEAR = new Date().getFullYear(); const MIN_YEAR = THIS_YEAR - 1; -// ── SQLite Cache (12h für Treffer, 1h für Leer-Ergebnisse) ─────────────────── -function dbCacheGet(key) { +// Sprachen die wir NICHT wollen +const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']); + +// Genre-IDs die wir nicht wollen (nur wenn das der EINZIGE Genre ist) +// 16=Animation, 99=Documentary – aber nur rausfiltern wenn explizit kein Spielfilm-Genre dabei +const DOC_GENRE_ID = 99; + +// ── SQLite Cache ────────────────────────────────────────────────────────────── +function cacheGet(key) { const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key); if (!row) return undefined; - if (Date.now() > row.expires_at) { - db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); - return undefined; - } + if (Date.now() > row.expires_at) { db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); return undefined; } try { return JSON.parse(row.value); } catch { return undefined; } } - -function dbCacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) { - db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key, value, expires_at) VALUES (?,?,?)') +function cacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) { + db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key,value,expires_at) VALUES (?,?,?)') .run(key, JSON.stringify(value), Date.now() + ttlMs); } @@ -29,7 +31,6 @@ function dbCacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) { function getToken() { return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; } - async function tmdb(path, params = {}) { const token = getToken(); if (!token) throw new Error('Kein TMDb API-Token konfiguriert'); @@ -37,87 +38,72 @@ async function tmdb(path, params = {}) { url.searchParams.set('language', 'de-DE'); url.searchParams.set('region', 'DE'); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } - }); - if (!res.ok) throw new Error(`TMDb Fehler: ${res.status}`); + const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); + if (!res.ok) throw new Error(`TMDb ${res.status}`); return res.json(); } - let genreCache = null; async function getGenreMap() { if (genreCache) return genreCache; - const data = await tmdb('/genre/movie/list'); + const d = await tmdb('/genre/movie/list'); genreCache = {}; - for (const g of data.genres) genreCache[g.id] = g.name; + for (const g of d.genres) genreCache[g.id] = g.name; return genreCache; } - -function extractFSK(releaseDates) { - const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); +function extractFSK(rd) { + const de = rd?.results?.find(r => r.iso_3166_1 === 'DE'); if (!de?.release_dates?.length) return null; - const sorted = [...de.release_dates].sort((a, b) => (a.type === 3 ? -1 : 1)); - for (const r of sorted) { - const c = (r.certification || '').trim(); - if (c) return c; - } + const sorted = [...de.release_dates].sort((a, b) => a.type === 3 ? -1 : 1); + for (const r of sorted) { const c = (r.certification||'').trim(); if (c) return c; } return null; } - async function fetchAllPages(path, maxPages = 3) { const first = await tmdb(path, { page: 1 }); const total = Math.min(first.total_pages || 1, maxPages); let results = [...first.results]; if (total > 1) { - const extra = await Promise.all( - Array.from({ length: total - 1 }, (_, i) => tmdb(path, { page: i + 2 })) - ); + const extra = await Promise.all(Array.from({ length: total-1 }, (_,i) => tmdb(path, { page: i+2 }))); for (const p of extra) results = results.concat(p.results); } return results; } - function filterMovies(list) { const seen = new Set(); return list.filter(m => { - if (m.original_language === 'tr') return false; - const y = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; + if (BLOCKED_LANGS.has(m.original_language)) return false; + const y = m.release_date ? parseInt(m.release_date.slice(0,4)) : 0; if (y > 0 && y < MIN_YEAR) return false; if (seen.has(m.id)) return false; seen.add(m.id); + // Reine Dokus rausfiltern + const genres = m.genre_ids || []; + if (genres.length === 1 && genres[0] === DOC_GENRE_ID) return false; + if (genres.length === 0 && m.vote_count < 5) return false; return true; }); } - -function enrichMovie(movie, genreMap, fsk, rank) { +function enrichMovie(m, genreMap, fsk, rank) { return { - id: movie.id, - title: movie.title || movie.original_title, - original_title: movie.original_title, - original_language: movie.original_language, - poster_path: movie.poster_path, - overview: movie.overview, - release_date: movie.release_date, - vote_average: movie.vote_average, - genres: (movie.genre_ids || []).map(id => genreMap[id]).filter(Boolean), - fsk: fsk ?? null, - rank: rank ?? null, + id: m.id, title: m.title||m.original_title, original_title: m.original_title, + original_language: m.original_language, poster_path: m.poster_path, + overview: m.overview, release_date: m.release_date, vote_average: m.vote_average, + genres: (m.genre_ids||[]).map(id => genreMap[id]).filter(Boolean), + fsk: fsk??null, rank: rank??null, }; } // ── xREL ───────────────────────────────────────────────────────────────────── +const QUALITY_BLOCKLIST = /\b(2160p|4k|uhd|uhdbluray|uhdbd)\b/i; -async function xrelFetch(path, params = {}, timeoutMs = 8000) { +async function xrelFetch(path, params = {}) { const url = new URL(XREL_BASE + path); - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); + for (const [k,v] of Object.entries(params)) url.searchParams.set(k, String(v)); const res = await fetch(url.toString(), { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(timeoutMs), + headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000), }); if (res.status === 429) { - const wait = parseInt(res.headers.get('Retry-After') || '15') * 1000; - await new Promise(r => setTimeout(r, wait)); - const r2 = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }); + await new Promise(r => setTimeout(r, (parseInt(res.headers.get('Retry-After')||'10'))*1000)); + const r2 = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000) }); if (!r2.ok) throw new Error(`xREL ${r2.status}`); return r2.json(); } @@ -125,209 +111,172 @@ async function xrelFetch(path, params = {}, timeoutMs = 8000) { return res.json(); } -function isGermanRelease(dirname, mainLang) { - if (mainLang === 'german') return true; - return /\b(german|deutsch)(\.dl)?\b/i.test(dirname || ''); -} - -// Sucht xREL ext_info_id über IMDb-ID (exakter Match – keine falschen Treffer) -// Falls keine IMDb-ID: Titel-Suche mit strengem Titel-Vergleich als Fallback -async function findExtInfoId(imdbId, title, originalTitle) { - const cacheKey = `extinfoid:${imdbId || originalTitle || title}`.toLowerCase(); - const cached = dbCacheGet(cacheKey); +// Einzige Funktion für xREL: benutzt immer die ext_info_id und gibt max 5 deutsche ≤1080p Releases +// Cache-Key: tmdb_id (immer eindeutig, konsistent zwischen Badge und Modal) +async function getXrelData(tmdbId, imdbId, title, originalTitle) { + const cacheKey = `xrel:tmdb:${tmdbId}`; + const cached = cacheGet(cacheKey); if (cached !== undefined) return cached; - // Strategie 1: IMDb-ID Suche via ext_info search (xREL gibt uris zurück) + let extInfoId = null; + + // Suche ext_info_id via IMDb-URI (exakter Match) if (imdbId) { - const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i); + const queries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); for (const q of queries) { try { - const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 10 }); - const hits = Array.isArray(data.results) ? data.results : []; - // Exakter Match über IMDb-URI - const match = hits.find(h => - Array.isArray(h.uris) && h.uris.includes(`imdb:${imdbId}`) - ); - if (match?.id) { - dbCacheSet(cacheKey, match.id); - return match.id; - } - } catch (_) {} + const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*]/g,''), type:'movie', limit:10 }); + const hit = (data.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`)); + if (hit?.id) { extInfoId = hit.id; break; } + } catch(_) {} } } - // Strategie 2: Titel-Suche mit strengem Vergleich (Fallback wenn keine IMDb-ID) - const cleanQ = (t) => (t || '').replace(/[*#@!?:]/g, '').replace(/\s+/g, ' ').trim().toLowerCase(); - const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i); - for (const q of queries) { - try { - const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#@!?:]/g, ''), type: 'movie', limit: 5 }); - const hits = Array.isArray(data.results) ? data.results : []; - // Nur exakter Titel-Match akzeptieren - const match = hits.find(h => cleanQ(h.title) === cleanQ(q) || cleanQ(h.alt_title) === cleanQ(q)); - if (match?.id) { - dbCacheSet(cacheKey, match.id); - return match.id; - } - } catch (_) {} + // Fallback: exakter Titel-Match + if (!extInfoId) { + const clean = (s) => (s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); + const queries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); + for (const q of queries) { + try { + const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#!?:]/g,''), type:'movie', limit:5 }); + const hit = (data.results||[]).find(h => clean(h.title)===clean(q) || clean(h.alt_title)===clean(q)); + if (hit?.id) { extInfoId = hit.id; break; } + } catch(_) {} + } } - dbCacheSet(cacheKey, null, 60 * 60 * 1000); // 1h für Nicht-Treffer - return null; -} - -// Holt alle deutschen Releases für eine ext_info_id -async function getGermanReleasesById(extInfoId) { - const cacheKey = `releases:${extInfoId}`; - const cached = dbCacheGet(cacheKey); - if (cached !== undefined) return cached; + if (!extInfoId) { + cacheSet(cacheKey, [], 60*60*1000); // 1h für nicht gefunden + return []; + } + // Releases holen: Scene + P2P parallel, nur german, nur ≤1080p, max 5 const [sceneData, p2pData] = await Promise.all([ - xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 100 }).catch(() => ({})), - xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 100 }).catch(() => ({})), + xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 }).catch(()=>({})), + xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 50 }).catch(()=>({})), ]); const results = []; - for (const r of sceneData.list || []) { - if (!isGermanRelease(r.dirname, null)) continue; + for (const r of sceneData.list||[]) { + if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue; + if (QUALITY_BLOCKLIST.test(r.dirname||'')) continue; results.push({ - dirname: r.dirname, - size: r.size ? `${r.size.number} ${r.size.unit}` : null, - category: 'Scene', - url: r.link_href, - date: r.time ? new Date(r.time * 1000).toISOString().slice(0, 10) : null, - p2p: false, + dirname: r.dirname, size: r.size ? `${r.size.number} ${r.size.unit}` : null, + category: 'Scene', url: r.link_href, + date: r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null, p2p: false, }); } - - // P2P: alle Seiten - const p2pPages = Math.min(p2pData.pagination?.total_pages || 1, 4); - const allP2P = [...(p2pData.list || [])]; - if (p2pPages > 1) { - const extra = await Promise.all( - Array.from({ length: p2pPages - 1 }, (_, i) => - xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 100, page: i + 2 }) - .then(d => d.list || []).catch(() => []) - ) - ); - for (const list of extra) allP2P.push(...list); - } - - for (const r of allP2P) { + for (const r of p2pData.list||[]) { if (r.main_lang !== 'german') continue; + if (QUALITY_BLOCKLIST.test(r.dirname||'')) continue; results.push({ - dirname: r.dirname, - size: r.size_mb ? `${(r.size_mb / 1024).toFixed(1)} GB` : null, - category: r.category?.sub_cat || 'P2P', - url: r.link_href, - date: r.pub_time ? new Date(r.pub_time * 1000).toISOString().slice(0, 10) : null, - p2p: true, + dirname: r.dirname, size: r.size_mb ? `${(r.size_mb/1024).toFixed(1)} GB` : null, + category: r.category?.sub_cat||'P2P', url: r.link_href, + date: r.pub_time ? new Date(r.pub_time*1000).toISOString().slice(0,10) : null, p2p: true, }); } - results.sort((a, b) => (b.date || '').localeCompare(a.date || '')); - const ttl = results.length > 0 ? 12 * 60 * 60 * 1000 : 60 * 60 * 1000; - dbCacheSet(cacheKey, results, ttl); - return results; + results.sort((a,b) => (b.date||'').localeCompare(a.date||'')); + const top5 = results.slice(0, 5); + cacheSet(cacheKey, top5, top5.length > 0 ? 12*60*60*1000 : 60*60*1000); + return top5; } // ── Routes ──────────────────────────────────────────────────────────────────── router.get('/now-playing', authenticate, async (req, res) => { try { - const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing', 3), getGenreMap()]); + const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing',3), getGenreMap()]); const filtered = filterMovies(pages); - const top10raw = filtered.slice(0, 10); - const restRaw = filtered.slice(10); - const top10 = await Promise.all(top10raw.map(async (m, i) => { + const top10raw = filtered.slice(0,10), restRaw = filtered.slice(10); + const top10 = await Promise.all(top10raw.map(async (m,i) => { let fsk = null; - try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {} - return enrichMovie(m, genreMap, fsk, i + 1); + try { fsk = extractFSK(await tmdb(`/movie/${m.id}/release_dates`)); } catch(_) {} + return enrichMovie(m, genreMap, fsk, i+1); })); - res.json([...top10, ...restRaw.map(m => enrichMovie(m, genreMap, null, null))]); - } catch (e) { res.status(500).json({ error: e.message }); } + res.json([...top10, ...restRaw.map(m => enrichMovie(m,genreMap,null,null))]); + } catch(e) { res.status(500).json({ error: e.message }); } }); router.get('/upcoming', authenticate, async (req, res) => { try { - const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming', 3), getGenreMap()]); + const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming',3), getGenreMap()]); const filtered = filterMovies(pages).filter(m => m.release_date); - filtered.sort((a, b) => a.release_date.localeCompare(b.release_date)); + filtered.sort((a,b) => a.release_date.localeCompare(b.release_date)); const groups = {}; for (const m of filtered) { - const d = new Date(m.release_date); - const dow = d.getDay(); - const mon = new Date(d); - mon.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow)); - const key = mon.toISOString().slice(0, 10); + const d = new Date(m.release_date), dow = d.getDay(); + const mon = new Date(d); mon.setDate(d.getDate()+(dow===0?-6:1-dow)); + const key = mon.toISOString().slice(0,10); if (!groups[key]) groups[key] = []; - groups[key].push(enrichMovie(m, genreMap, null, null)); + groups[key].push(enrichMovie(m,genreMap,null,null)); } res.json(groups); - } catch (e) { res.status(500).json({ error: e.message }); } + } catch(e) { res.status(500).json({ error: e.message }); } }); +// Modal: TMDb-Daten sofort, xREL via separaten Endpoint router.get('/movie/:id', authenticate, async (req, res) => { try { const [detail, genreMap, extIds] = await Promise.all([ tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }), getGenreMap(), - tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})), + tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})), ]); - const fsk = extractFSK(detail.release_dates); - const imdbId = extIds.imdb_id || detail.imdb_id || null; - - let xrel = []; - try { - const extInfoId = await findExtInfoId(imdbId, detail.title, detail.original_title); - if (extInfoId) xrel = await getGermanReleasesById(extInfoId); - } catch (_) {} - + const fsk = extractFSK(detail.release_dates); + const imdbId = extIds.imdb_id || null; res.json({ - id: detail.id, - title: detail.title || detail.original_title, - original_title: detail.original_title, - tagline: detail.tagline, - overview: detail.overview, - poster_path: detail.poster_path, - backdrop_path: detail.backdrop_path, - release_date: detail.release_date, - runtime: detail.runtime, - vote_average: detail.vote_average, - genres: (detail.genres || []).map(g => g.name), - fsk, - cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name), - director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null, - tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`, - imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null, - xrel, - has_xrel: xrel.length > 0, + id: detail.id, title: detail.title||detail.original_title, + original_title: detail.original_title, tagline: detail.tagline, + overview: detail.overview, poster_path: detail.poster_path, + backdrop_path: detail.backdrop_path, release_date: detail.release_date, + runtime: detail.runtime, vote_average: detail.vote_average, + genres: (detail.genres||[]).map(g => g.name), fsk, + cast: (detail.credits?.cast||[]).slice(0,8).map(a => a.name), + director: detail.credits?.crew?.find(c => c.job==='Director')?.name||null, + tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`, + imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null, + imdb_id: imdbId, }); - } catch (e) { res.status(500).json({ error: e.message }); } + } catch(e) { res.status(500).json({ error: e.message }); } }); -// POST /api/tools/media/xrel-check – Badge Check mit IMDb-ID -// Frontend schickt { movies: [{ id, title, original_title, imdb_id }] } +// xREL separat – lädt im Hintergrund, cached per tmdb_id +router.get('/movie/:id/xrel', authenticate, async (req, res) => { + try { + const cacheKey = `xrel:tmdb:${req.params.id}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return res.json({ xrel: cached, has_xrel: cached.length > 0 }); + + // Brauchen Titel + IMDb-ID + const [detail, extIds] = await Promise.all([ + tmdb(`/movie/${req.params.id}`, { append_to_response: '' }).catch(()=>({})), + tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})), + ]); + const imdbId = extIds.imdb_id || null; + const xrel = await getXrelData(req.params.id, imdbId, detail.title, detail.original_title); + res.json({ xrel, has_xrel: xrel.length > 0 }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// Badge-Check: benutzt denselben Cache wie Modal router.post('/xrel-check', authenticate, async (req, res) => { - const movies = (req.body.movies || []).slice(0, 1); + const movies = (req.body.movies||[]).slice(0,1); if (!movies.length) return res.json({}); const m = movies[0]; + const cacheKey = `xrel:tmdb:${m.id}`; try { - const extInfoId = await findExtInfoId(m.imdb_id || null, m.title, m.original_title); - if (!extInfoId) return res.json({ [m.id]: false }); - const releases = await getGermanReleasesById(extInfoId); - res.json({ [m.id]: releases.length > 0 }); - } catch (_) { - res.json({ [m.id]: false }); - } + const cached = cacheGet(cacheKey); + if (cached !== undefined) return res.json({ [m.id]: cached.length > 0 }); + const xrel = await getXrelData(m.id, m.imdb_id||null, m.title, m.original_title); + res.json({ [m.id]: xrel.length > 0 }); + } catch(_) { res.json({ [m.id]: false }); } }); router.get('/favorites', authenticate, (req, res) => { - const rows = db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC').all(req.user.id); - res.json(rows); + res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id)); }); - router.post('/favorites', authenticate, (req, res) => { const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk } = req.body; if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' }); @@ -335,18 +284,13 @@ router.post('/favorites', authenticate, (req, res) => { db.prepare(`INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk) VALUES (?,?,?,?,?,?,?,?)`) .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||''); res.json({ ok: true }); - } catch (e) { res.status(500).json({ error: e.message }); } + } catch(e) { res.status(500).json({ error: e.message }); } }); - router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id)); res.json({ ok: true }); }); - -router.get('/settings', authenticate, requireAdmin, (req, res) => { - res.json({ configured: !!getToken() }); -}); - +router.get('/settings', authenticate, requireAdmin, (req, res) => res.json({ configured: !!getToken() })); router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); @@ -354,11 +298,8 @@ router.put('/settings', authenticate, requireAdmin, (req, res) => { genreCache = null; res.json({ ok: true }); }); - -// DELETE /api/tools/media/xrel-cache – Cache leeren (Admin) router.delete('/xrel-cache', authenticate, requireAdmin, (req, res) => { - const info = db.prepare('DELETE FROM xrel_cache').run(); - res.json({ deleted: info.changes }); + res.json({ deleted: db.prepare('DELETE FROM xrel_cache').run().changes }); }); module.exports = router; diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index c3385ab..6478b7a 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -476,15 +476,14 @@ function MovieModal({ tmdbId, onClose, mobile }) { useEffect(() => { setLoading(true); setDetail(null); setXrel(null); setError(''); + // TMDb-Daten sofort laden und anzeigen api(`/tools/media/movie/${tmdbId}`) - .then(d => { - // TMDb-Daten sofort anzeigen - setDetail(d); - setLoading(false); - // xREL ist bereits im response enthalten (Backend lädt parallel) - setXrel(Array.isArray(d.xrel) ? d.xrel : []); - }) + .then(d => { setDetail(d); setLoading(false); }) .catch(e => { setError(e.message); setLoading(false); }); + // xREL separat und unabhängig nachladen + api(`/tools/media/movie/${tmdbId}/xrel`) + .then(d => setXrel(d.xrel || [])) + .catch(() => setXrel([])); // Body-Scroll sperren const prev = document.body.style.overflow; document.body.style.overflow = 'hidden';