From 2f277c43d9a8e898728b720fc61277dfbb062430 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 6 Jun 2026 22:05:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20media=20=E2=80=93=20FSK=20alle=20Eintr?= =?UTF-8?q?=C3=A4ge,=20Duplikate,=20Jahres-Filter,=20xREL=20Response-Struk?= =?UTF-8?q?tur,=20xREL-Cover-Badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/media/routes.js | 208 +++++++++++++++++++++--------- frontend/src/tools/media.jsx | 59 +++++++-- 2 files changed, 193 insertions(+), 74 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 8c7385b..7bb6a66 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -6,6 +6,9 @@ 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; // nur Filme ab Vorjahr + function getToken() { return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; } @@ -24,7 +27,7 @@ async function tmdb(path, params = {}) { return res.json(); } -// Genre-Cache +// Genre-Cache (in-memory) let genreCache = null; async function getGenreMap() { if (genreCache) return genreCache; @@ -34,16 +37,17 @@ async function getGenreMap() { return genreCache; } -// FSK-Fix: alle release_dates durchsuchen, ersten nicht-leeren Cert nehmen -// Bevorzugt Typ 3 (Kino), aber nimmt jeden nicht-leeren Wert +// FSK: alle Einträge prüfen, bevorzugt Typ 3 (Kino), jeden nicht-leeren nehmen function extractFSK(releaseDates) { const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); - if (!de) return null; - // Erst Kino (type=3), dann alle anderen - const sorted = [...de.release_dates].sort((a, b) => - (a.type === 3 ? 0 : 1) - (b.type === 3 ? 0 : 1) - ); - return sorted.find(r => r.certification && r.certification.trim() !== '')?.certification || null; + if (!de?.release_dates?.length) return null; + // Sortierung: Typ 3 (Kino) zuerst, dann alle anderen + 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) { @@ -59,38 +63,117 @@ async function fetchAllPages(path, maxPages = 3) { return results; } +function movieYear(m) { + return m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; +} + +function filterMovies(list) { + const seen = new Set(); + return list.filter(m => { + if (m.original_language === 'tr') return false; + const y = movieYear(m); + if (y > 0 && y < MIN_YEAR) return false; + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); +} + function enrichMovie(movie, 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, + fsk: fsk ?? null, + rank: rank ?? null, }; } +// xREL: Suche nach Titel, gibt bereinigte Release-Liste zurück +async function fetchXrelReleases(title, originalTitle, year) { + const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i); + const results = []; + const seen = new Set(); + + for (const q of queries) { + try { + // Scene + const sceneUrl = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=true&p2p=false&limit=25`; + const sceneRes = await fetch(sceneUrl, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(5000) }); + if (sceneRes.ok) { + const data = await sceneRes.json(); + // Response: { results: [...] } – flat array für search + const hits = Array.isArray(data.results) ? data.results : []; + for (const r of hits) { + if (seen.has(r.dirname)) continue; + // Jahr-Check: dirname oder time + if (year && r.time) { + const rYear = new Date(r.time * 1000).getFullYear(); + if (Math.abs(rYear - year) > 1) continue; + } + seen.add(r.dirname); + results.push({ ...r, p2p: false }); + } + } + } catch (_) {} + + try { + // P2P + const p2pUrl = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=false&p2p=true&limit=25`; + const p2pRes = await fetch(p2pUrl, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(5000) }); + if (p2pRes.ok) { + const data = await p2pRes.json(); + const hits = Array.isArray(data.results) ? data.results : []; + for (const r of hits) { + if (seen.has(r.dirname)) continue; + if (year && r.time) { + const rYear = new Date(r.time * 1000).getFullYear(); + if (Math.abs(rYear - year) > 1) continue; + } + seen.add(r.dirname); + results.push({ ...r, p2p: true }); + } + } + } catch (_) {} + } + + // Sortieren: neueste zuerst + results.sort((a, b) => (b.time || 0) - (a.time || 0)); + + return results.slice(0, 20).map(r => ({ + dirname: r.dirname, + size: r.size_mb ? `${(r.size_mb / 1024).toFixed(1)} GB` : null, + category: r.category?.name || '', + url: r.link_href || `https://www.xrel.to/release/${r.id}.html`, + date: r.time ? new Date(r.time * 1000).toISOString().slice(0, 10) : null, + p2p: !!r.p2p, + })); +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + // GET /api/tools/media/now-playing router.get('/now-playing', authenticate, async (req, res) => { try { - const [pages, genreMap] = await Promise.all([ - fetchAllPages('/movie/now_playing', 3), - getGenreMap(), - ]); - const filtered = pages.filter(m => m.original_language !== 'tr'); + const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing', 3), getGenreMap()]); + const filtered = filterMovies(pages); + + // Top 10: FSK parallel holen const top10raw = filtered.slice(0, 10); const restRaw = filtered.slice(10); - // Top 10: FSK parallel holen 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); })); + const rest = restRaw.map(m => enrichMovie(m, genreMap, null, null)); res.json([...top10, ...rest]); } catch (e) { @@ -101,23 +184,16 @@ router.get('/now-playing', authenticate, async (req, res) => { // GET /api/tools/media/upcoming router.get('/upcoming', authenticate, async (req, res) => { try { - const [pages, genreMap] = await Promise.all([ - fetchAllPages('/movie/upcoming', 3), - getGenreMap(), - ]); - const seen = new Set(); - const filtered = pages - .filter(m => m.original_language !== 'tr') - .filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; }) - .filter(m => m.release_date); + 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)); const groups = {}; for (const m of filtered) { const d = new Date(m.release_date); const dow = d.getDay(); - const diff = dow === 0 ? -6 : 1 - dow; - const mon = new Date(d); mon.setDate(d.getDate() + diff); + 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)); @@ -128,7 +204,7 @@ router.get('/upcoming', authenticate, async (req, res) => { } }); -// GET /api/tools/media/movie/:id – Details + Credits + xREL + Links +// GET /api/tools/media/movie/:id – Details + xREL-Check fürs Cover-Badge router.get('/movie/:id', authenticate, async (req, res) => { try { const [detail, genreMap, extIds] = await Promise.all([ @@ -137,38 +213,11 @@ router.get('/movie/:id', authenticate, async (req, res) => { tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})), ]); - const fsk = extractFSK(detail.release_dates); - const imdb_id = extIds.imdb_id || detail.imdb_id || null; - const tmdb_url = `https://www.themoviedb.org/movie/${detail.id}`; - const imdb_url = imdb_id ? `https://www.imdb.com/title/${imdb_id}` : null; + const fsk = extractFSK(detail.release_dates); + const imdb_id = extIds.imdb_id || detail.imdb_id || null; + const year = detail.release_date ? parseInt(detail.release_date.slice(0, 4)) : 0; - // xREL-Suche: original_title + Jahr - let xrel = []; - try { - const year = detail.release_date?.slice(0, 4) || ''; - const query = encodeURIComponent(detail.original_title || detail.title); - const xUrl = `${XREL_BASE}/search/releases.json?q=${query}&scene=true&p2p=true&limit=25`; - const xRes = await fetch(xUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) }); - if (xRes.ok) { - const xData = await xRes.json(); - const hits = (xData.results?.scene?.releases || []).concat(xData.results?.p2p?.releases || []); - // Nur Movie-Releases, nach Erscheinungsjahr filtern wenn bekannt - xrel = hits - .filter(r => { - const cat = r.category?.name || ''; - return /movie|film|xvid|x264|x265|bluray|uhd|dvdr|hdtv/i.test(cat); - }) - .map(r => ({ - dirname: r.dirname, - size: r.size_mb ? `${(r.size_mb/1024).toFixed(1)} GB` : null, - category: r.category?.name || '', - url: r.link_href || `https://www.xrel.to/release/${r.id}.html`, - date: r.time ? new Date(r.time * 1000).toISOString().slice(0,10) : null, - p2p: !!r.p2p, - })) - .slice(0, 15); - } - } catch (_) {} + const xrel = await fetchXrelReleases(detail.title, detail.original_title, year); res.json({ id: detail.id, @@ -186,15 +235,50 @@ router.get('/movie/:id', authenticate, async (req, res) => { 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, - imdb_url, + tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`, + imdb_url: imdb_id ? `https://www.imdb.com/title/${imdb_id}` : null, xrel, + has_xrel: xrel.length > 0, }); } catch (e) { res.status(500).json({ error: e.message }); } }); +// GET /api/tools/media/xrel-check – Batch-Check ob xREL-Releases existieren (für Cover-Badges) +// Body: { movies: [{ id, title, original_title, release_date }] } +router.post('/xrel-check', authenticate, async (req, res) => { + const movies = req.body.movies || []; + if (!movies.length) return res.json({}); + + // Max 5 parallel um xREL nicht zu überlasten + const result = {}; + const chunks = []; + for (let i = 0; i < movies.length; i += 5) chunks.push(movies.slice(i, i + 5)); + + for (const chunk of chunks) { + await Promise.all(chunk.map(async m => { + try { + const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; + const q = encodeURIComponent(m.original_title || m.title); + const url = `${XREL_BASE}/search/releases.json?q=${q}&scene=true&p2p=true&limit=5`; + const r = await fetch(url, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(4000) }); + if (!r.ok) { result[m.id] = false; return; } + const data = await r.json(); + const hits = Array.isArray(data.results) ? data.results : []; + const found = hits.some(h => { + if (!year || !h.time) return true; + return Math.abs(new Date(h.time * 1000).getFullYear() - year) <= 1; + }); + result[m.id] = found; + } catch (_) { + result[m.id] = false; + } + })); + } + res.json(result); +}); + // GET /api/tools/media/favorites router.get('/favorites', authenticate, (req, res) => { const rows = db.prepare( diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index fed3fe8..05c356f 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -136,10 +136,11 @@ export default function Media({ toast, mobile }) { } // ── Film-Karte ──────────────────────────────────────────────────────────────── -function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { - const isFav = favIds.has(movie.id); - const isTop = movie.rank != null && movie.rank <= 10; +function MovieCard({ movie, favIds, xrelIds, onToggleFav, onOpenModal }) { + const isFav = favIds.has(movie.id); + const isTop = movie.rank != null && movie.rank <= 10; const fskStyle = FSK_COLOR(movie.fsk); + const hasXrel = xrelIds?.has(movie.id); return (
)} + {/* xREL-Badge */} + {hasXrel && ( +
+ xREL +
+ )} {/* Fav */}
- {movies.map(m => )} + {movies.map(m => )}
))}