fix: xREL Cache (6h), 429-Retry, 600ms Pause zwischen Requests
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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; };
|
||||
|
||||
Reference in New Issue
Block a user