From 0d819b719c016d15390f95001f141fbdd0b9e371 Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 10 Jun 2026 23:01:55 +0200 Subject: [PATCH] fix: xREL IMDb-ID exact match via uris[], strenger Titel-Vergleich als Fallback --- backend/src/tools/media/routes.js | 216 +++++++++++++++++------------- 1 file changed, 124 insertions(+), 92 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index b4f05c6..792d75e 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -9,15 +9,7 @@ const XREL_BASE = 'https://api.xrel.to/v2'; const THIS_YEAR = new Date().getFullYear(); const MIN_YEAR = THIS_YEAR - 1; -// German release filter: dirname contains german, german.dl, ger.dl, deutsch etc. -function isGermanRelease(dirname, mainLang) { - if (mainLang === 'german') return true; - return /\b(german|deutsch)(\.dl)?\b/i.test(dirname || ''); -} - -// ── SQLite Cache ────────────────────────────────────────────────────────────── -const CACHE_TTL = 12 * 60 * 60 * 1000; // 12h - +// ── SQLite Cache (12h für Treffer, 1h für Leer-Ergebnisse) ─────────────────── function dbCacheGet(key) { const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key); if (!row) return undefined; @@ -28,9 +20,9 @@ function dbCacheGet(key) { try { return JSON.parse(row.value); } catch { return undefined; } } -function dbCacheSet(key, value) { +function dbCacheSet(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() + CACHE_TTL); + .run(key, JSON.stringify(value), Date.now() + ttlMs); } // ── TMDb ────────────────────────────────────────────────────────────────────── @@ -113,98 +105,130 @@ function enrichMovie(movie, genreMap, fsk, rank) { }; } -// ── xREL: Ein einzelner Search-Call pro Film ───────────────────────────────── -// /search/releases.json gibt scene (results) + p2p (p2p_results) zurück -// Jeder Eintrag hat ext_info.title für exakten Match + main_lang für German-Filter -// Das spart den ext_info_id Lookup komplett +// ── xREL ───────────────────────────────────────────────────────────────────── -// Sonderzeichen aus Suchtitel entfernen die xREL verwirren -function cleanTitle(t) { - return (t || '').replace(/[*#@!?]/g, '').replace(/\s+/g, ' ').trim(); +async function xrelFetch(path, params = {}, timeoutMs = 8000) { + 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.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) }); + if (!r2.ok) throw new Error(`xREL ${r2.status}`); + return r2.json(); + } + if (!res.ok) throw new Error(`xREL ${res.status}`); + return res.json(); } -async function xrelSearchGerman(title, originalTitle) { - const cleanOrig = cleanTitle(originalTitle); - const cleanTitle_ = cleanTitle(title); - const cacheKey = `xrel:${cleanOrig || cleanTitle_}`.toLowerCase(); +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); if (cached !== undefined) return cached; - // Queries: original ohne Sonderzeichen, dann deutsch, dann ohne Artikel - const rawQueries = [cleanOrig, cleanTitle_] - .filter(Boolean) - .filter((v, i, a) => a.indexOf(v) === i); - - // Zusätzlich: deutschen Artikel entfernen (Der/Die/Das am Anfang) - const extraQueries = rawQueries - .map(q => q.replace(/^(Der|Die|Das|Ein|Eine)\s+/i, '').trim()) - .filter(q => q && !rawQueries.includes(q)); - - const queries = [...rawQueries, ...extraQueries]; - - const results = []; - const seen = new Set(); + // Strategie 1: IMDb-ID Suche via ext_info search (xREL gibt uris zurück) + if (imdbId) { + 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 (_) {} + } + } + // 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 url = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=true&p2p=true&limit=50`; - const res = await fetch(url, { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(8000), - }); - if (res.status === 429) { - const wait = parseInt(res.headers.get('Retry-After') || '15') * 1000; - await new Promise(r => setTimeout(r, wait)); - continue; + 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; } - if (!res.ok) continue; - const data = await res.json(); - - // Scene releases - for (const r of (data.results || [])) { - if (seen.has(r.dirname)) continue; - if (!isGermanRelease(r.dirname, null)) continue; - seen.add(r.dirname); - 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, - }); - } - - // P2P releases - for (const r of (data.p2p_results || [])) { - if (seen.has(r.dirname)) continue; - if (!isGermanRelease(r.dirname, r.main_lang)) continue; - seen.add(r.dirname); - const sizeMb = r.size_mb || 0; - results.push({ - dirname: r.dirname, - size: sizeMb ? `${(sizeMb / 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, - }); - } - - // Wenn wir Treffer haben nach erstem Query, nicht nochmal suchen - if (results.length > 0) break; - } catch (_) {} } - results.sort((a, b) => (b.date || '').localeCompare(a.date || '')); - // Leere Ergebnisse nur kurz cachen (30 Min) damit xREL später nochmal gefragt wird - if (results.length > 0) { - dbCacheSet(cacheKey, results); - } else { - db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key, value, expires_at) VALUES (?,?,?)') - .run(cacheKey, '[]', Date.now() + 30 * 60 * 1000); + 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; + + 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(() => ({})), + ]); + + const results = []; + + for (const r of sceneData.list || []) { + if (!isGermanRelease(r.dirname, null)) 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, + }); } + + // 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) { + if (r.main_lang !== 'german') 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, + }); + } + + 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; } @@ -253,7 +277,12 @@ router.get('/movie/:id', authenticate, async (req, res) => { ]); const fsk = extractFSK(detail.release_dates); const imdbId = extIds.imdb_id || detail.imdb_id || null; - const xrel = await xrelSearchGerman(detail.title, detail.original_title); + + let xrel = []; + try { + const extInfoId = await findExtInfoId(imdbId, detail.title, detail.original_title); + if (extInfoId) xrel = await getGermanReleasesById(extInfoId); + } catch (_) {} res.json({ id: detail.id, @@ -278,13 +307,16 @@ router.get('/movie/:id', authenticate, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// POST /api/tools/media/xrel-check – Badge Check, 1 Film pro Request, SQLite-Cache +// POST /api/tools/media/xrel-check – Badge Check mit IMDb-ID +// Frontend schickt { movies: [{ id, title, original_title, imdb_id }] } router.post('/xrel-check', authenticate, async (req, res) => { const movies = (req.body.movies || []).slice(0, 1); if (!movies.length) return res.json({}); const m = movies[0]; try { - const releases = await xrelSearchGerman(m.title, m.original_title); + 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 });