fix: xREL IMDb-ID exact match via uris[], strenger Titel-Vergleich als Fallback
This commit is contained in:
@@ -9,15 +9,7 @@ const XREL_BASE = 'https://api.xrel.to/v2';
|
|||||||
const THIS_YEAR = new Date().getFullYear();
|
const THIS_YEAR = new Date().getFullYear();
|
||||||
const MIN_YEAR = THIS_YEAR - 1;
|
const MIN_YEAR = THIS_YEAR - 1;
|
||||||
|
|
||||||
// German release filter: dirname contains german, german.dl, ger.dl, deutsch etc.
|
// ── SQLite Cache (12h für Treffer, 1h für Leer-Ergebnisse) ───────────────────
|
||||||
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) {
|
function dbCacheGet(key) {
|
||||||
const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key);
|
const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key);
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
@@ -28,9 +20,9 @@ function dbCacheGet(key) {
|
|||||||
try { return JSON.parse(row.value); } catch { return undefined; }
|
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 (?,?,?)')
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── TMDb ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -113,98 +105,130 @@ function enrichMovie(movie, genreMap, fsk, rank) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xREL: Ein einzelner Search-Call pro Film ─────────────────────────────────
|
// ── xREL ─────────────────────────────────────────────────────────────────────
|
||||||
// /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
|
|
||||||
|
|
||||||
// Sonderzeichen aus Suchtitel entfernen die xREL verwirren
|
async function xrelFetch(path, params = {}, timeoutMs = 8000) {
|
||||||
function cleanTitle(t) {
|
const url = new URL(XREL_BASE + path);
|
||||||
return (t || '').replace(/[*#@!?]/g, '').replace(/\s+/g, ' ').trim();
|
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) {
|
function isGermanRelease(dirname, mainLang) {
|
||||||
const cleanOrig = cleanTitle(originalTitle);
|
if (mainLang === 'german') return true;
|
||||||
const cleanTitle_ = cleanTitle(title);
|
return /\b(german|deutsch)(\.dl)?\b/i.test(dirname || '');
|
||||||
const cacheKey = `xrel:${cleanOrig || cleanTitle_}`.toLowerCase();
|
}
|
||||||
|
|
||||||
|
// 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);
|
const cached = dbCacheGet(cacheKey);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
// Queries: original ohne Sonderzeichen, dann deutsch, dann ohne Artikel
|
// Strategie 1: IMDb-ID Suche via ext_info search (xREL gibt uris zurück)
|
||||||
const rawQueries = [cleanOrig, cleanTitle_]
|
if (imdbId) {
|
||||||
.filter(Boolean)
|
const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i);
|
||||||
.filter((v, i, a) => a.indexOf(v) === i);
|
for (const q of queries) {
|
||||||
|
try {
|
||||||
// Zusätzlich: deutschen Artikel entfernen (Der/Die/Das am Anfang)
|
const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 10 });
|
||||||
const extraQueries = rawQueries
|
const hits = Array.isArray(data.results) ? data.results : [];
|
||||||
.map(q => q.replace(/^(Der|Die|Das|Ein|Eine)\s+/i, '').trim())
|
// Exakter Match über IMDb-URI
|
||||||
.filter(q => q && !rawQueries.includes(q));
|
const match = hits.find(h =>
|
||||||
|
Array.isArray(h.uris) && h.uris.includes(`imdb:${imdbId}`)
|
||||||
const queries = [...rawQueries, ...extraQueries];
|
);
|
||||||
|
if (match?.id) {
|
||||||
const results = [];
|
dbCacheSet(cacheKey, match.id);
|
||||||
const seen = new Set();
|
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) {
|
for (const q of queries) {
|
||||||
try {
|
try {
|
||||||
const url = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=true&p2p=true&limit=50`;
|
const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#@!?:]/g, ''), type: 'movie', limit: 5 });
|
||||||
const res = await fetch(url, {
|
const hits = Array.isArray(data.results) ? data.results : [];
|
||||||
headers: { Accept: 'application/json' },
|
// Nur exakter Titel-Match akzeptieren
|
||||||
signal: AbortSignal.timeout(8000),
|
const match = hits.find(h => cleanQ(h.title) === cleanQ(q) || cleanQ(h.alt_title) === cleanQ(q));
|
||||||
});
|
if (match?.id) {
|
||||||
if (res.status === 429) {
|
dbCacheSet(cacheKey, match.id);
|
||||||
const wait = parseInt(res.headers.get('Retry-After') || '15') * 1000;
|
return match.id;
|
||||||
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
dbCacheSet(cacheKey, null, 60 * 60 * 1000); // 1h für Nicht-Treffer
|
||||||
// Leere Ergebnisse nur kurz cachen (30 Min) damit xREL später nochmal gefragt wird
|
return null;
|
||||||
if (results.length > 0) {
|
}
|
||||||
dbCacheSet(cacheKey, results);
|
|
||||||
} else {
|
// Holt alle deutschen Releases für eine ext_info_id
|
||||||
db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key, value, expires_at) VALUES (?,?,?)')
|
async function getGermanReleasesById(extInfoId) {
|
||||||
.run(cacheKey, '[]', Date.now() + 30 * 60 * 1000);
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +277,12 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
const fsk = extractFSK(detail.release_dates);
|
const fsk = extractFSK(detail.release_dates);
|
||||||
const imdbId = extIds.imdb_id || detail.imdb_id || null;
|
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({
|
res.json({
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
@@ -278,13 +307,16 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} 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) => {
|
router.post('/xrel-check', authenticate, async (req, res) => {
|
||||||
const movies = (req.body.movies || []).slice(0, 1);
|
const movies = (req.body.movies || []).slice(0, 1);
|
||||||
if (!movies.length) return res.json({});
|
if (!movies.length) return res.json({});
|
||||||
const m = movies[0];
|
const m = movies[0];
|
||||||
try {
|
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 });
|
res.json({ [m.id]: releases.length > 0 });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
res.json({ [m.id]: false });
|
res.json({ [m.id]: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user