From ec3afbbd3c98a746e905dcadd99c833612d72fbd Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 6 Jun 2026 22:25:49 +0200 Subject: [PATCH] fix: xREL korrekte Response-Struktur (results/p2p_results), German-Filter, Size-Objekt --- backend/src/tools/media/routes.js | 186 ++++++++++++++---------------- 1 file changed, 87 insertions(+), 99 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 7bb6a66..32343ea 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -7,7 +7,7 @@ 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 +const MIN_YEAR = THIS_YEAR - 1; function getToken() { return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; @@ -27,7 +27,6 @@ async function tmdb(path, params = {}) { return res.json(); } -// Genre-Cache (in-memory) let genreCache = null; async function getGenreMap() { if (genreCache) return genreCache; @@ -37,15 +36,14 @@ async function getGenreMap() { return genreCache; } -// 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?.release_dates?.length) return null; - // Sortierung: Typ 3 (Kino) zuerst, dann alle anderen + // Typ 3 (Kino) bevorzugen, sonst alle 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; + if (c) return c; } return null; } @@ -63,15 +61,11 @@ 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); + 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); @@ -95,76 +89,93 @@ function enrichMovie(movie, genreMap, fsk, rank) { }; } -// xREL: Suche nach Titel, gibt bereinigte Release-Liste zurück +// xREL: Suche, Response-Struktur: { results: [...scene], p2p_results: [...p2p] } +// Size-Objekt: { number, unit } +// Nur German-Releases (dirname enthält "german" oder "deutsch", case-insensitive) +function isGerman(dirname) { + return /german|deutsch/i.test(dirname || ''); +} + +function formatSize(size) { + if (!size || !size.number) return null; + const n = size.number; + const u = (size.unit || '').toLowerCase(); + if (u === 'mb') return `${(n / 1024).toFixed(1)} GB`; + if (u === 'gb') return `${n.toFixed ? n.toFixed(1) : n} GB`; + return `${n} ${size.unit}`; +} + +async function xrelSearch(query, signal) { + const q = encodeURIComponent(query); + const url = `${XREL_BASE}/search/releases.json?q=${q}&scene=true&p2p=true&limit=25`; + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal }); + if (!res.ok) return { scene: [], p2p: [] }; + const data = await res.json(); + return { + scene: Array.isArray(data.results) ? data.results : [], + p2p: Array.isArray(data.p2p_results) ? data.p2p_results : [], + }; +} + +function processXrelHits(hits, isP2P, year) { + return hits + .filter(r => isGerman(r.dirname)) + .filter(r => { + if (!year || !r.time) return true; + return Math.abs(new Date(r.time * 1000).getFullYear() - year) <= 1; + }) + .map(r => ({ + dirname: r.dirname, + size: formatSize(r.size), + category: r.ext_info?.type || (isP2P ? 'P2P' : 'Scene'), + 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: isP2P, + })); +} + async function fetchXrelReleases(title, originalTitle, year) { - const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i); - const results = []; + const queries = [originalTitle, title].filter(Boolean) + .filter((v, i, a) => a.indexOf(v) === i); + const seen = new Set(); + let results = []; + const sig = AbortSignal.timeout(6000); 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 }); - } + const { scene, p2p } = await xrelSearch(q, sig); + for (const r of [...processXrelHits(scene, false, year), ...processXrelHits(p2p, true, year)]) { + if (!seen.has(r.dirname)) { seen.add(r.dirname); results.push(r); } } } catch (_) {} } - // Sortieren: neueste zuerst - results.sort((a, b) => (b.time || 0) - (a.time || 0)); + results.sort((a, b) => (b.date || '').localeCompare(a.date || '')); + return results.slice(0, 20); +} - 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, - })); +async function xrelHasGermanRelease(title, originalTitle, year) { + const queries = [originalTitle, title].filter(Boolean) + .filter((v, i, a) => a.indexOf(v) === i); + const sig = AbortSignal.timeout(5000); + for (const q of queries) { + try { + const { scene, p2p } = await xrelSearch(q, sig); + const all = [...scene, ...p2p]; + if (all.some(r => isGerman(r.dirname) && (!year || !r.time || Math.abs(new Date(r.time*1000).getFullYear()-year)<=1))) + return true; + } catch (_) {} + } + return false; } // ── 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 = filterMovies(pages); - - // Top 10: FSK parallel holen const top10raw = filtered.slice(0, 10); const restRaw = filtered.slice(10); @@ -173,7 +184,6 @@ router.get('/now-playing', authenticate, async (req, res) => { 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) { @@ -181,7 +191,6 @@ 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()]); @@ -190,7 +199,7 @@ router.get('/upcoming', authenticate, async (req, res) => { const groups = {}; for (const m of filtered) { - const d = new Date(m.release_date); + 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)); @@ -204,7 +213,6 @@ router.get('/upcoming', authenticate, async (req, res) => { } }); -// 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([ @@ -212,12 +220,10 @@ router.get('/movie/:id', authenticate, async (req, res) => { getGenreMap(), 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 year = detail.release_date ? parseInt(detail.release_date.slice(0, 4)) : 0; - - const xrel = await fetchXrelReleases(detail.title, detail.original_title, year); + const fsk = extractFSK(detail.release_dates); + const imdbId = extIds.imdb_id || detail.imdb_id || null; + const year = detail.release_date ? parseInt(detail.release_date.slice(0, 4)) : 0; + const xrel = await fetchXrelReleases(detail.title, detail.original_title, year); res.json({ id: detail.id, @@ -236,7 +242,7 @@ router.get('/movie/:id', authenticate, async (req, res) => { 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: imdb_id ? `https://www.imdb.com/title/${imdb_id}` : null, + imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null, xrel, has_xrel: xrel.length > 0, }); @@ -245,41 +251,27 @@ router.get('/movie/:id', authenticate, async (req, res) => { } }); -// GET /api/tools/media/xrel-check – Batch-Check ob xREL-Releases existieren (für Cover-Badges) -// Body: { movies: [{ id, title, original_title, release_date }] } +// POST /api/tools/media/xrel-check – Batch-Check für Cover-Badges router.post('/xrel-check', authenticate, async (req, res) => { - const movies = req.body.movies || []; + const movies = (req.body.movies || []).slice(0, 30); if (!movies.length) return res.json({}); - // Max 5 parallel um xREL nicht zu überlasten const result = {}; + // 3er-Batches um xREL Rate-Limits zu respektieren const chunks = []; - for (let i = 0; i < movies.length; i += 5) chunks.push(movies.slice(i, i + 5)); + for (let i = 0; i < movies.length; i += 3) chunks.push(movies.slice(i, i + 3)); 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; - } + const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; + result[m.id] = await xrelHasGermanRelease(m.title, m.original_title, year); })); + // Kurze Pause zwischen Batches + await new Promise(r => setTimeout(r, 200)); } res.json(result); }); -// GET /api/tools/media/favorites router.get('/favorites', authenticate, (req, res) => { const rows = db.prepare( 'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC' @@ -287,7 +279,6 @@ router.get('/favorites', authenticate, (req, res) => { res.json(rows); }); -// POST /api/tools/media/favorites 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' }); @@ -304,19 +295,16 @@ router.post('/favorites', authenticate, (req, res) => { } }); -// DELETE /api/tools/media/favorites/:tmdb_id 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 }); }); -// GET /api/tools/media/settings router.get('/settings', authenticate, requireAdmin, (req, res) => { res.json({ configured: !!getToken() }); }); -// PUT /api/tools/media/settings 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' });