fix: xREL 2-Schritt (ext_info_id), korrekte Response-Struktur, P2P main_lang Filter

This commit is contained in:
2026-06-06 22:48:49 +02:00
parent ec3afbbd3c
commit 1eb8cf9b19

View File

@@ -39,7 +39,6 @@ async function getGenreMap() {
function extractFSK(releaseDates) { function extractFSK(releaseDates) {
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
if (!de?.release_dates?.length) return null; 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)); const sorted = [...de.release_dates].sort((a, b) => (a.type === 3 ? -1 : 1));
for (const r of sorted) { for (const r of sorted) {
const c = (r.certification || '').trim(); const c = (r.certification || '').trim();
@@ -89,85 +88,90 @@ function enrichMovie(movie, genreMap, fsk, rank) {
}; };
} }
// xREL: Suche, Response-Struktur: { results: [...scene], p2p_results: [...p2p] } // ── xREL: 2-Schritt Ansatz ────────────────────────────────────────────────────
// Size-Objekt: { number, unit } // Schritt 1: Titel → ext_info_id (eindeutige xREL Film-ID)
// Nur German-Releases (dirname enthält "german" oder "deutsch", case-insensitive) // Schritt 2: ext_info_id → alle Releases (Scene + P2P)
function isGerman(dirname) { // So gibt es keine falschen Film-Matches mehr.
return /german|deutsch/i.test(dirname || '');
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) { // Findet die passende ext_info_id für einen Film-Titel
if (!size || !size.number) return null; // Prüft ob Titel ähnlich genug ist (exakte oder starke Übereinstimmung)
const n = size.number; async function findExtInfoId(title, originalTitle) {
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) {
const queries = [originalTitle, title].filter(Boolean) const queries = [originalTitle, title].filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i); .filter((v, i, a) => v && a.indexOf(v) === i);
const seen = new Set();
let results = [];
const sig = AbortSignal.timeout(6000);
for (const q of queries) { for (const q of queries) {
try { try {
const { scene, p2p } = await xrelSearch(q, sig); const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 5 });
for (const r of [...processXrelHits(scene, false, year), ...processXrelHits(p2p, true, year)]) { // data.results = Array von ShortExtInfo
if (!seen.has(r.dirname)) { seen.add(r.dirname); results.push(r); } 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 (_) {} } catch (_) {}
} }
return null;
results.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
return results.slice(0, 20);
} }
async function xrelHasGermanRelease(title, originalTitle, year) { // Holt alle deutschen Releases für eine ext_info_id
const queries = [originalTitle, title].filter(Boolean) async function getGermanReleasesById(extInfoId) {
.filter((v, i, a) => a.indexOf(v) === i); const results = [];
const sig = AbortSignal.timeout(5000);
for (const q of queries) { try {
try { // Scene Releases: { list: [...Release], total_count, pagination }
const { scene, p2p } = await xrelSearch(q, sig); const sceneData = await xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 });
const all = [...scene, ...p2p]; const sceneList = Array.isArray(sceneData.list) ? sceneData.list : [];
if (all.some(r => isGerman(r.dirname) && (!year || !r.time || Math.abs(new Date(r.time*1000).getFullYear()-year)<=1))) for (const r of sceneList) {
return true; // Scene: German-Filter über dirname
} catch (_) {} if (!/german|deutsch/i.test(r.dirname || '')) continue;
} results.push({
return false; 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 ──────────────────────────────────────────────────────────────────── // ── Routes ────────────────────────────────────────────────────────────────────
@@ -178,17 +182,13 @@ router.get('/now-playing', authenticate, async (req, res) => {
const filtered = filterMovies(pages); const filtered = filterMovies(pages);
const top10raw = filtered.slice(0, 10); const top10raw = filtered.slice(0, 10);
const restRaw = filtered.slice(10); const restRaw = filtered.slice(10);
const top10 = await Promise.all(top10raw.map(async (m, i) => { const top10 = await Promise.all(top10raw.map(async (m, i) => {
let fsk = null; let fsk = null;
try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {} try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {}
return enrichMovie(m, genreMap, fsk, i + 1); return enrichMovie(m, genreMap, fsk, i + 1);
})); }));
const rest = restRaw.map(m => enrichMovie(m, genreMap, null, null)); res.json([...top10, ...restRaw.map(m => enrichMovie(m, genreMap, null, null))]);
res.json([...top10, ...rest]); } catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
router.get('/upcoming', authenticate, async (req, res) => { 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 [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming', 3), getGenreMap()]);
const filtered = filterMovies(pages).filter(m => m.release_date); const filtered = filterMovies(pages).filter(m => m.release_date);
filtered.sort((a, b) => a.release_date.localeCompare(b.release_date)); filtered.sort((a, b) => a.release_date.localeCompare(b.release_date));
const groups = {}; const groups = {};
for (const m of filtered) { for (const m of filtered) {
const d = new Date(m.release_date); 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)); groups[key].push(enrichMovie(m, genreMap, null, null));
} }
res.json(groups); res.json(groups);
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
router.get('/movie/:id', authenticate, async (req, res) => { 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 fsk = extractFSK(detail.release_dates);
const imdbId = extIds.imdb_id || detail.imdb_id || null; 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({ res.json({
id: detail.id, id: detail.id,
@@ -236,7 +238,6 @@ router.get('/movie/:id', authenticate, async (req, res) => {
release_date: detail.release_date, release_date: detail.release_date,
runtime: detail.runtime, runtime: detail.runtime,
vote_average: detail.vote_average, vote_average: detail.vote_average,
vote_count: detail.vote_count,
genres: (detail.genres || []).map(g => g.name), genres: (detail.genres || []).map(g => g.name),
fsk, fsk,
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name), cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
@@ -246,36 +247,35 @@ router.get('/movie/:id', authenticate, async (req, res) => {
xrel, xrel,
has_xrel: xrel.length > 0, has_xrel: xrel.length > 0,
}); });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
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) => { 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({}); if (!movies.length) return res.json({});
const result = {}; const result = {};
// 3er-Batches um xREL Rate-Limits zu respektieren // 2er-Batches (2 API-Calls pro Film: search + releases)
const chunks = []; for (let i = 0; i < movies.length; i += 2) {
for (let i = 0; i < movies.length; i += 3) chunks.push(movies.slice(i, i + 3)); const chunk = movies.slice(i, i + 2);
for (const chunk of chunks) {
await Promise.all(chunk.map(async m => { await Promise.all(chunk.map(async m => {
const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0; try {
result[m.id] = await xrelHasGermanRelease(m.title, m.original_title, year); 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 if (i + 2 < movies.length) await new Promise(r => setTimeout(r, 300));
await new Promise(r => setTimeout(r, 200));
} }
res.json(result); res.json(result);
}); });
router.get('/favorites', authenticate, (req, res) => { router.get('/favorites', authenticate, (req, res) => {
const rows = db.prepare( const rows = db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC').all(req.user.id);
'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC'
).all(req.user.id);
res.json(rows); 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; 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' }); if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' });
try { try {
db.prepare( db.prepare(`INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk) VALUES (?,?,?,?,?,?,?,?)`)
`INSERT OR IGNORE INTO movie_favorites .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||'');
(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 }); res.json({ ok: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?') db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id));
.run(req.user.id, Number(req.params.tmdb_id));
res.json({ ok: true }); res.json({ ok: true });
}); });