diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index d168cb8..34b2826 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -9,6 +9,31 @@ 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 + +function dbCacheGet(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; + } + try { return JSON.parse(row.value); } catch { return undefined; } +} + +function dbCacheSet(key, value) { + db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key, value, expires_at) VALUES (?,?,?)') + .run(key, JSON.stringify(value), Date.now() + CACHE_TTL); +} + +// ── TMDb ────────────────────────────────────────────────────────────────────── function getToken() { return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; } @@ -88,117 +113,76 @@ function enrichMovie(movie, genreMap, fsk, rank) { }; } -// ── xREL Cache ─────────────────────────────────────────────────────────────── -// In-Memory Cache für xREL Ergebnisse – vermeidet Rate-Limits bei wiederholten Abfragen -const xrelCache = new Map(); // key → { value, expiresAt } -const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 Stunden +// ── 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 -function cacheGet(key) { - const entry = xrelCache.get(key); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { xrelCache.delete(key); return undefined; } - return entry.value; -} - -function cacheSet(key, value) { - xrelCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL }); -} - -// ── xREL Helper ─────────────────────────────────────────────────────────────── - -async function xrelFetch(path, params = {}, timeoutMs = 7000) { - 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) { - // Rate-Limit: Retry-After Header auslesen oder 10s warten - const retryAfter = parseInt(res.headers.get('Retry-After') || '10'); - await new Promise(r => setTimeout(r, retryAfter * 1000)); - // Nochmal versuchen - const res2 = await fetch(url.toString(), { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!res2.ok) throw new Error(`xREL ${res2.status}`); - return res2.json(); - } - if (!res.ok) throw new Error(`xREL ${res.status}`); - return res.json(); -} - -// Findet ext_info_id: sucht original + deutsch parallel, nimmt ersten Treffer -async function findExtInfoId(title, originalTitle) { - const cacheKey = `extinfo:${originalTitle || title}`; - const cached = cacheGet(cacheKey); +async function xrelSearchGerman(title, originalTitle) { + const cacheKey = `xrel:${originalTitle || title}`.toLowerCase(); + const cached = dbCacheGet(cacheKey); if (cached !== undefined) return cached; - const queries = [originalTitle, title] - .filter(Boolean) + 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 { - const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 5 }, 5000); - const hits = Array.isArray(data.results) ? data.results : []; - const id = hits.find(h => h.type === 'movie')?.id || null; - if (id) { cacheSet(cacheKey, id); return id; } + 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; + } + 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 (_) {} } - cacheSet(cacheKey, null); - return null; -} - -// Holt alle deutschen Releases für eine ext_info_id – Scene + P2P parallel -async function getGermanReleasesById(extInfoId) { - const [sceneData, p2pData] = await Promise.all([ - xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 100 }, 6000).catch(() => ({})), - xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 100 }, 6000).catch(() => ({})), - ]); - - const results = []; - - // Scene - for (const r of sceneData.list || []) { - 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, - date: r.time ? new Date(r.time * 1000).toISOString().slice(0, 10) : null, - p2p: false, - }); - } - - // P2P – alle Seiten parallel holen - const p2pTotalPages = Math.min(p2pData.pagination?.total_pages || 1, 4); - const allP2P = [...(p2pData.list || [])]; - if (p2pTotalPages > 1) { - const extra = await Promise.all( - Array.from({ length: p2pTotalPages - 1 }, (_, i) => - xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 100, page: i + 2 }, 6000) - .then(d => d.list || []).catch(() => []) - ) - ); - for (const list of extra) allP2P.push(...list); - } - for (const r of allP2P) { - 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: 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 || '')); + dbCacheSet(cacheKey, results); return results; } @@ -247,20 +231,7 @@ router.get('/movie/:id', authenticate, async (req, res) => { ]); const fsk = extractFSK(detail.release_dates); const imdbId = extIds.imdb_id || detail.imdb_id || null; - - // xREL: aus Cache oder frisch laden - let xrel = []; - try { - const xrelCacheKey = `releases:${detail.original_title || detail.title}`; - const cachedXrel = cacheGet(xrelCacheKey); - if (cachedXrel !== undefined) { - xrel = cachedXrel; - } else { - const extInfoId = await findExtInfoId(detail.title, detail.original_title); - if (extInfoId) xrel = await getGermanReleasesById(extInfoId); - cacheSet(xrelCacheKey, xrel); - } - } catch (_) {} + const xrel = await xrelSearchGerman(detail.title, detail.original_title); res.json({ id: detail.id, @@ -285,39 +256,17 @@ router.get('/movie/:id', authenticate, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// POST /api/tools/media/xrel-check – Check ob deutsche Releases existieren -// Sequenziell + Cache – Rate-Limit-sicher +// POST /api/tools/media/xrel-check – Badge Check, 1 Film pro Request, SQLite-Cache router.post('/xrel-check', authenticate, async (req, res) => { - const movies = (req.body.movies || []).slice(0, 5); + const movies = (req.body.movies || []).slice(0, 1); if (!movies.length) return res.json({}); - - const result = {}; - for (const m of movies) { - const cacheKey = `hasde:${m.original_title || m.title}`; - const cached = cacheGet(cacheKey); - if (cached !== undefined) { - result[m.id] = cached; - continue; - } - try { - const extInfoId = await findExtInfoId(m.title, m.original_title); - if (!extInfoId) { cacheSet(cacheKey, false); result[m.id] = false; continue; } - // P2P zuerst - const p2p = await xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 25 }, 8000); - if ((p2p.list || []).some(r => r.main_lang === 'german')) { - cacheSet(cacheKey, true); result[m.id] = true; continue; - } - // Scene als Fallback - const scene = await xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 25 }, 8000); - const found = (scene.list || []).some(r => /german|deutsch/i.test(r.dirname || '')); - cacheSet(cacheKey, found); result[m.id] = found; - } catch (_) { - result[m.id] = false; - } - // Pause zwischen unkacheid Requests - await new Promise(r => setTimeout(r, 600)); + const m = movies[0]; + try { + const releases = await xrelSearchGerman(m.title, m.original_title); + res.json({ [m.id]: releases.length > 0 }); + } catch (_) { + res.json({ [m.id]: false }); } - res.json(result); }); router.get('/favorites', authenticate, (req, res) => { diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index ba7e5c9..02e2e88 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -280,7 +280,6 @@ function KinoGrid({ onOpenModal }) { const hits = Object.entries(result).filter(([,v]) => v).map(([k]) => Number(k)); if (hits.length > 0) setXrelIds(prev => new Set([...prev, ...hits])); } catch (_) {} - await new Promise(r => setTimeout(r, 800)); } })(); return () => { cancelled = true; }; @@ -350,7 +349,6 @@ function DemnächstGrid({ onOpenModal }) { const hits = Object.entries(result).filter(([,v]) => v).map(([k]) => Number(k)); if (hits.length > 0) setXrelIds(prev => new Set([...prev, ...hits])); } catch (_) {} - await new Promise(r => setTimeout(r, 800)); } })(); return () => { cancelled = true; };