diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 32343ea..da1489b 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -39,7 +39,6 @@ async function getGenreMap() { function extractFSK(releaseDates) { const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); if (!de?.release_dates?.length) return null; - // 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(); @@ -89,85 +88,90 @@ function enrichMovie(movie, genreMap, fsk, rank) { }; } -// 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 || ''); +// ── xREL: 2-Schritt Ansatz ──────────────────────────────────────────────────── +// Schritt 1: Titel → ext_info_id (eindeutige xREL Film-ID) +// Schritt 2: ext_info_id → alle Releases (Scene + P2P) +// So gibt es keine falschen Film-Matches mehr. + +async function xrelFetch(path, params = {}, timeoutMs = 6000) { + const url = new URL(XREL_BASE + path); + 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), + }); + if (!res.ok) throw new Error(`xREL ${res.status}`); + return res.json(); } -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) { +// Findet die passende ext_info_id für einen Film-Titel +// Prüft ob Titel ähnlich genug ist (exakte oder starke Übereinstimmung) +async function findExtInfoId(title, originalTitle) { 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); + .filter((v, i, a) => v && a.indexOf(v) === i); for (const q of queries) { try { - 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); } + const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 5 }); + // data.results = Array von ShortExtInfo + const hits = Array.isArray(data.results) ? data.results : []; + for (const hit of hits) { + const hitTitle = (hit.title || '').toLowerCase().trim(); + const searchQ = q.toLowerCase().trim(); + // Titel muss sehr ähnlich sein – exakt oder beginnt damit + if (hitTitle === searchQ || hitTitle.startsWith(searchQ) || searchQ.startsWith(hitTitle)) { + return hit.id; + } } } catch (_) {} } - - results.sort((a, b) => (b.date || '').localeCompare(a.date || '')); - return results.slice(0, 20); + return null; } -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; +// Holt alle deutschen Releases für eine ext_info_id +async function getGermanReleasesById(extInfoId) { + const results = []; + + try { + // Scene Releases: { list: [...Release], total_count, pagination } + const sceneData = await xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 }); + const sceneList = Array.isArray(sceneData.list) ? sceneData.list : []; + for (const r of sceneList) { + // Scene: German-Filter über dirname + if (!/german|deutsch/i.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 || `https://www.xrel.to/release/${r.id}.html`, + date: r.time ? new Date(r.time * 1000).toISOString().slice(0, 10) : null, + p2p: false, + }); + } + } catch (_) {} + + try { + // P2P Releases: { list: [...P2PRelease], total_count, pagination } + const p2pData = await xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 50 }); + const p2pList = Array.isArray(p2pData.list) ? p2pData.list : []; + for (const r of p2pList) { + // P2P: German-Filter über main_lang Feld (zuverlässig!) + if (r.main_lang !== 'german') continue; + const sizeMb = r.size_mb || 0; + results.push({ + dirname: r.dirname, + size: sizeMb ? `${(sizeMb / 1024).toFixed(1)} GB` : null, + category: 'P2P', + url: r.link_href || `https://www.xrel.to/p2p-release/${r.id}.html`, + date: r.pub_time ? new Date(r.pub_time * 1000).toISOString().slice(0, 10) : null, + p2p: true, + }); + } + } catch (_) {} + + // Neueste zuerst + results.sort((a, b) => (b.date || '').localeCompare(a.date || '')); + return results; } // ── Routes ──────────────────────────────────────────────────────────────────── @@ -178,17 +182,13 @@ router.get('/now-playing', authenticate, async (req, res) => { 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) => { 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) { - 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) => { @@ -196,7 +196,6 @@ router.get('/upcoming', authenticate, async (req, res) => { 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); @@ -208,9 +207,7 @@ router.get('/upcoming', authenticate, async (req, res) => { 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 }); } }); router.get('/movie/:id', authenticate, async (req, res) => { @@ -222,8 +219,13 @@ router.get('/movie/:id', authenticate, async (req, res) => { ]); 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); + + // xREL: 2-Schritt + let xrel = []; + try { + const extInfoId = await findExtInfoId(detail.title, detail.original_title); + if (extInfoId) xrel = await getGermanReleasesById(extInfoId); + } catch (_) {} res.json({ id: detail.id, @@ -236,7 +238,6 @@ router.get('/movie/:id', authenticate, async (req, res) => { release_date: detail.release_date, runtime: detail.runtime, vote_average: detail.vote_average, - vote_count: detail.vote_count, genres: (detail.genres || []).map(g => g.name), fsk, cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name), @@ -246,36 +247,35 @@ router.get('/movie/:id', authenticate, async (req, res) => { xrel, has_xrel: xrel.length > 0, }); - } catch (e) { - res.status(500).json({ error: e.message }); - } + } catch (e) { res.status(500).json({ error: e.message }); } }); -// POST /api/tools/media/xrel-check – Batch-Check für Cover-Badges +// POST /api/tools/media/xrel-check – Cover-Badge Batch-Check router.post('/xrel-check', authenticate, async (req, res) => { - const movies = (req.body.movies || []).slice(0, 30); + const movies = (req.body.movies || []).slice(0, 20); if (!movies.length) return res.json({}); const result = {}; - // 3er-Batches um xREL Rate-Limits zu respektieren - const chunks = []; - for (let i = 0; i < movies.length; i += 3) chunks.push(movies.slice(i, i + 3)); - - for (const chunk of chunks) { + // 2er-Batches (2 API-Calls pro Film: search + releases) + for (let i = 0; i < movies.length; i += 2) { + const chunk = movies.slice(i, i + 2); await Promise.all(chunk.map(async m => { - const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; - result[m.id] = await xrelHasGermanRelease(m.title, m.original_title, year); + try { + const extInfoId = await findExtInfoId(m.title, m.original_title); + if (!extInfoId) { result[m.id] = false; return; } + const releases = await getGermanReleasesById(extInfoId); + result[m.id] = releases.length > 0; + } catch (_) { + result[m.id] = false; + } })); - // Kurze Pause zwischen Batches - await new Promise(r => setTimeout(r, 200)); + if (i + 2 < movies.length) await new Promise(r => setTimeout(r, 300)); } res.json(result); }); 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); + 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); }); @@ -283,21 +283,14 @@ 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' }); try { - 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||''); + 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)); + 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 }); });