fix: xREL korrekte Response-Struktur (results/p2p_results), German-Filter, Size-Objekt
This commit is contained in:
@@ -7,7 +7,7 @@ const TMDB_BASE = 'https://api.themoviedb.org/3';
|
|||||||
const XREL_BASE = 'https://api.xrel.to/v2';
|
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; // nur Filme ab Vorjahr
|
const MIN_YEAR = THIS_YEAR - 1;
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || '';
|
return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || '';
|
||||||
@@ -27,7 +27,6 @@ async function tmdb(path, params = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genre-Cache (in-memory)
|
|
||||||
let genreCache = null;
|
let genreCache = null;
|
||||||
async function getGenreMap() {
|
async function getGenreMap() {
|
||||||
if (genreCache) return genreCache;
|
if (genreCache) return genreCache;
|
||||||
@@ -37,15 +36,14 @@ async function getGenreMap() {
|
|||||||
return genreCache;
|
return genreCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FSK: alle Einträge prüfen, bevorzugt Typ 3 (Kino), jeden nicht-leeren nehmen
|
|
||||||
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;
|
||||||
// Sortierung: Typ 3 (Kino) zuerst, dann alle anderen
|
// 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();
|
||||||
if (c !== '') return c;
|
if (c) return c;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -63,15 +61,11 @@ async function fetchAllPages(path, maxPages = 3) {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function movieYear(m) {
|
|
||||||
return m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterMovies(list) {
|
function filterMovies(list) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return list.filter(m => {
|
return list.filter(m => {
|
||||||
if (m.original_language === 'tr') return false;
|
if (m.original_language === 'tr') return false;
|
||||||
const y = movieYear(m);
|
const y = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0;
|
||||||
if (y > 0 && y < MIN_YEAR) return false;
|
if (y > 0 && y < MIN_YEAR) return false;
|
||||||
if (seen.has(m.id)) return false;
|
if (seen.has(m.id)) return false;
|
||||||
seen.add(m.id);
|
seen.add(m.id);
|
||||||
@@ -95,76 +89,93 @@ function enrichMovie(movie, genreMap, fsk, rank) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// xREL: Suche nach Titel, gibt bereinigte Release-Liste zurück
|
// xREL: Suche, Response-Struktur: { results: [...scene], p2p_results: [...p2p] }
|
||||||
|
// Size-Objekt: { number, unit }
|
||||||
|
// Nur German-Releases (dirname enthält "german" oder "deutsch", case-insensitive)
|
||||||
|
function isGerman(dirname) {
|
||||||
|
return /german|deutsch/i.test(dirname || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(size) {
|
||||||
|
if (!size || !size.number) return null;
|
||||||
|
const n = size.number;
|
||||||
|
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) {
|
async function fetchXrelReleases(title, originalTitle, year) {
|
||||||
const queries = [originalTitle, title].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i);
|
const queries = [originalTitle, title].filter(Boolean)
|
||||||
const results = [];
|
.filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
let results = [];
|
||||||
|
const sig = AbortSignal.timeout(6000);
|
||||||
|
|
||||||
for (const q of queries) {
|
for (const q of queries) {
|
||||||
try {
|
try {
|
||||||
// Scene
|
const { scene, p2p } = await xrelSearch(q, sig);
|
||||||
const sceneUrl = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=true&p2p=false&limit=25`;
|
for (const r of [...processXrelHits(scene, false, year), ...processXrelHits(p2p, true, year)]) {
|
||||||
const sceneRes = await fetch(sceneUrl, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(5000) });
|
if (!seen.has(r.dirname)) { seen.add(r.dirname); results.push(r); }
|
||||||
if (sceneRes.ok) {
|
|
||||||
const data = await sceneRes.json();
|
|
||||||
// Response: { results: [...] } – flat array für search
|
|
||||||
const hits = Array.isArray(data.results) ? data.results : [];
|
|
||||||
for (const r of hits) {
|
|
||||||
if (seen.has(r.dirname)) continue;
|
|
||||||
// Jahr-Check: dirname oder time
|
|
||||||
if (year && r.time) {
|
|
||||||
const rYear = new Date(r.time * 1000).getFullYear();
|
|
||||||
if (Math.abs(rYear - year) > 1) continue;
|
|
||||||
}
|
|
||||||
seen.add(r.dirname);
|
|
||||||
results.push({ ...r, p2p: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
||||||
|
return results.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function xrelHasGermanRelease(title, originalTitle, year) {
|
||||||
|
const queries = [originalTitle, title].filter(Boolean)
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
const sig = AbortSignal.timeout(5000);
|
||||||
|
for (const q of queries) {
|
||||||
try {
|
try {
|
||||||
// P2P
|
const { scene, p2p } = await xrelSearch(q, sig);
|
||||||
const p2pUrl = `${XREL_BASE}/search/releases.json?q=${encodeURIComponent(q)}&scene=false&p2p=true&limit=25`;
|
const all = [...scene, ...p2p];
|
||||||
const p2pRes = await fetch(p2pUrl, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(5000) });
|
if (all.some(r => isGerman(r.dirname) && (!year || !r.time || Math.abs(new Date(r.time*1000).getFullYear()-year)<=1)))
|
||||||
if (p2pRes.ok) {
|
return true;
|
||||||
const data = await p2pRes.json();
|
|
||||||
const hits = Array.isArray(data.results) ? data.results : [];
|
|
||||||
for (const r of hits) {
|
|
||||||
if (seen.has(r.dirname)) continue;
|
|
||||||
if (year && r.time) {
|
|
||||||
const rYear = new Date(r.time * 1000).getFullYear();
|
|
||||||
if (Math.abs(rYear - year) > 1) continue;
|
|
||||||
}
|
|
||||||
seen.add(r.dirname);
|
|
||||||
results.push({ ...r, p2p: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
// Sortieren: neueste zuerst
|
|
||||||
results.sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
||||||
|
|
||||||
return results.slice(0, 20).map(r => ({
|
|
||||||
dirname: r.dirname,
|
|
||||||
size: r.size_mb ? `${(r.size_mb / 1024).toFixed(1)} GB` : null,
|
|
||||||
category: r.category?.name || '',
|
|
||||||
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: !!r.p2p,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/tools/media/now-playing
|
|
||||||
router.get('/now-playing', authenticate, async (req, res) => {
|
router.get('/now-playing', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing', 3), getGenreMap()]);
|
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing', 3), getGenreMap()]);
|
||||||
const filtered = filterMovies(pages);
|
const filtered = filterMovies(pages);
|
||||||
|
|
||||||
// Top 10: FSK parallel holen
|
|
||||||
const top10raw = filtered.slice(0, 10);
|
const top10raw = filtered.slice(0, 10);
|
||||||
const restRaw = filtered.slice(10);
|
const restRaw = filtered.slice(10);
|
||||||
|
|
||||||
@@ -173,7 +184,6 @@ router.get('/now-playing', authenticate, async (req, res) => {
|
|||||||
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));
|
const rest = restRaw.map(m => enrichMovie(m, genreMap, null, null));
|
||||||
res.json([...top10, ...rest]);
|
res.json([...top10, ...rest]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -181,7 +191,6 @@ router.get('/now-playing', authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/upcoming
|
|
||||||
router.get('/upcoming', authenticate, async (req, res) => {
|
router.get('/upcoming', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming', 3), getGenreMap()]);
|
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming', 3), getGenreMap()]);
|
||||||
@@ -204,7 +213,6 @@ router.get('/upcoming', authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/movie/:id – Details + xREL-Check fürs Cover-Badge
|
|
||||||
router.get('/movie/:id', authenticate, async (req, res) => {
|
router.get('/movie/:id', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [detail, genreMap, extIds] = await Promise.all([
|
const [detail, genreMap, extIds] = await Promise.all([
|
||||||
@@ -212,11 +220,9 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
getGenreMap(),
|
getGenreMap(),
|
||||||
tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})),
|
tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fsk = extractFSK(detail.release_dates);
|
const fsk = extractFSK(detail.release_dates);
|
||||||
const imdb_id = 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 year = detail.release_date ? parseInt(detail.release_date.slice(0, 4)) : 0;
|
||||||
|
|
||||||
const xrel = await fetchXrelReleases(detail.title, detail.original_title, year);
|
const xrel = await fetchXrelReleases(detail.title, detail.original_title, year);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -236,7 +242,7 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
|
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
|
||||||
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
|
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
|
||||||
tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`,
|
tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`,
|
||||||
imdb_url: imdb_id ? `https://www.imdb.com/title/${imdb_id}` : null,
|
imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null,
|
||||||
xrel,
|
xrel,
|
||||||
has_xrel: xrel.length > 0,
|
has_xrel: xrel.length > 0,
|
||||||
});
|
});
|
||||||
@@ -245,41 +251,27 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/xrel-check – Batch-Check ob xREL-Releases existieren (für Cover-Badges)
|
// POST /api/tools/media/xrel-check – Batch-Check für Cover-Badges
|
||||||
// Body: { movies: [{ id, title, original_title, release_date }] }
|
|
||||||
router.post('/xrel-check', authenticate, async (req, res) => {
|
router.post('/xrel-check', authenticate, async (req, res) => {
|
||||||
const movies = req.body.movies || [];
|
const movies = (req.body.movies || []).slice(0, 30);
|
||||||
if (!movies.length) return res.json({});
|
if (!movies.length) return res.json({});
|
||||||
|
|
||||||
// Max 5 parallel um xREL nicht zu überlasten
|
|
||||||
const result = {};
|
const result = {};
|
||||||
|
// 3er-Batches um xREL Rate-Limits zu respektieren
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for (let i = 0; i < movies.length; i += 5) chunks.push(movies.slice(i, i + 5));
|
for (let i = 0; i < movies.length; i += 3) chunks.push(movies.slice(i, i + 3));
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await Promise.all(chunk.map(async m => {
|
await Promise.all(chunk.map(async m => {
|
||||||
try {
|
|
||||||
const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0;
|
const year = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0;
|
||||||
const q = encodeURIComponent(m.original_title || m.title);
|
result[m.id] = await xrelHasGermanRelease(m.title, m.original_title, year);
|
||||||
const url = `${XREL_BASE}/search/releases.json?q=${q}&scene=true&p2p=true&limit=5`;
|
|
||||||
const r = await fetch(url, { headers:{ Accept:'application/json' }, signal: AbortSignal.timeout(4000) });
|
|
||||||
if (!r.ok) { result[m.id] = false; return; }
|
|
||||||
const data = await r.json();
|
|
||||||
const hits = Array.isArray(data.results) ? data.results : [];
|
|
||||||
const found = hits.some(h => {
|
|
||||||
if (!year || !h.time) return true;
|
|
||||||
return Math.abs(new Date(h.time * 1000).getFullYear() - year) <= 1;
|
|
||||||
});
|
|
||||||
result[m.id] = found;
|
|
||||||
} catch (_) {
|
|
||||||
result[m.id] = false;
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
// Kurze Pause zwischen Batches
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
}
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/favorites
|
|
||||||
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'
|
'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC'
|
||||||
@@ -287,7 +279,6 @@ router.get('/favorites', authenticate, (req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/tools/media/favorites
|
|
||||||
router.post('/favorites', authenticate, (req, res) => {
|
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' });
|
||||||
@@ -304,19 +295,16 @@ router.post('/favorites', authenticate, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/tools/media/favorites/:tmdb_id
|
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/settings
|
|
||||||
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
||||||
res.json({ configured: !!getToken() });
|
res.json({ configured: !!getToken() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/tools/media/settings
|
|
||||||
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
||||||
const { tmdb_token } = req.body;
|
const { tmdb_token } = req.body;
|
||||||
if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' });
|
if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' });
|
||||||
|
|||||||
Reference in New Issue
Block a user