346 lines
14 KiB
JavaScript
346 lines
14 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const db = require('../../db');
|
||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||
|
||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||
const XREL_BASE = 'https://api.xrel.to/v2';
|
||
|
||
const THIS_YEAR = new Date().getFullYear();
|
||
const MIN_YEAR = THIS_YEAR - 1;
|
||
|
||
function getToken() {
|
||
return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || '';
|
||
}
|
||
|
||
async function tmdb(path, params = {}) {
|
||
const token = getToken();
|
||
if (!token) throw new Error('Kein TMDb API-Token konfiguriert');
|
||
const url = new URL(TMDB_BASE + path);
|
||
url.searchParams.set('language', 'de-DE');
|
||
url.searchParams.set('region', 'DE');
|
||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||
const res = await fetch(url.toString(), {
|
||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
|
||
});
|
||
if (!res.ok) throw new Error(`TMDb Fehler: ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
let genreCache = null;
|
||
async function getGenreMap() {
|
||
if (genreCache) return genreCache;
|
||
const data = await tmdb('/genre/movie/list');
|
||
genreCache = {};
|
||
for (const g of data.genres) genreCache[g.id] = g.name;
|
||
return genreCache;
|
||
}
|
||
|
||
function extractFSK(releaseDates) {
|
||
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
|
||
if (!de?.release_dates?.length) return null;
|
||
const sorted = [...de.release_dates].sort((a, b) => (a.type === 3 ? -1 : 1));
|
||
for (const r of sorted) {
|
||
const c = (r.certification || '').trim();
|
||
if (c) return c;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function fetchAllPages(path, maxPages = 3) {
|
||
const first = await tmdb(path, { page: 1 });
|
||
const total = Math.min(first.total_pages || 1, maxPages);
|
||
let results = [...first.results];
|
||
if (total > 1) {
|
||
const extra = await Promise.all(
|
||
Array.from({ length: total - 1 }, (_, i) => tmdb(path, { page: i + 2 }))
|
||
);
|
||
for (const p of extra) results = results.concat(p.results);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function filterMovies(list) {
|
||
const seen = new Set();
|
||
return list.filter(m => {
|
||
if (m.original_language === 'tr') return false;
|
||
const y = m.release_date ? parseInt(m.release_date.slice(0, 4)) : 0;
|
||
if (y > 0 && y < MIN_YEAR) return false;
|
||
if (seen.has(m.id)) return false;
|
||
seen.add(m.id);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function enrichMovie(movie, genreMap, fsk, rank) {
|
||
return {
|
||
id: movie.id,
|
||
title: movie.title || movie.original_title,
|
||
original_title: movie.original_title,
|
||
original_language: movie.original_language,
|
||
poster_path: movie.poster_path,
|
||
overview: movie.overview,
|
||
release_date: movie.release_date,
|
||
vote_average: movie.vote_average,
|
||
genres: (movie.genre_ids || []).map(id => genreMap[id]).filter(Boolean),
|
||
fsk: fsk ?? null,
|
||
rank: rank ?? null,
|
||
};
|
||
}
|
||
|
||
// ── xREL: 2-Schritt Ansatz ────────────────────────────────────────────────────
|
||
// Schritt 1: Titel → ext_info_id (eindeutige xREL Film-ID)
|
||
// Schritt 2: ext_info_id → alle Releases (Scene + P2P)
|
||
// So gibt es keine falschen Film-Matches mehr.
|
||
|
||
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();
|
||
}
|
||
|
||
// Findet die passende ext_info_id für einen Film-Titel
|
||
// Prüft ob Titel ähnlich genug ist (exakte oder starke Übereinstimmung)
|
||
async function findExtInfoId(title, originalTitle) {
|
||
const queries = [originalTitle, title].filter(Boolean)
|
||
.filter((v, i, a) => v && a.indexOf(v) === i);
|
||
|
||
for (const q of queries) {
|
||
try {
|
||
const data = await xrelFetch('/search/ext_info.json', { q, type: 'movie', limit: 5 });
|
||
// data.results = Array von ShortExtInfo
|
||
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 (_) {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Holt alle deutschen Releases für eine ext_info_id
|
||
async function getGermanReleasesById(extInfoId) {
|
||
const results = [];
|
||
|
||
try {
|
||
// Scene Releases: { list: [...Release], total_count, pagination }
|
||
const sceneData = await xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 });
|
||
const sceneList = Array.isArray(sceneData.list) ? sceneData.list : [];
|
||
for (const r of sceneList) {
|
||
// Scene: German-Filter über dirname
|
||
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 || `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 ────────────────────────────────────────────────────────────────────
|
||
|
||
router.get('/now-playing', authenticate, async (req, res) => {
|
||
try {
|
||
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing', 3), getGenreMap()]);
|
||
const filtered = filterMovies(pages);
|
||
const top10raw = filtered.slice(0, 10);
|
||
const restRaw = filtered.slice(10);
|
||
const top10 = await Promise.all(top10raw.map(async (m, i) => {
|
||
let fsk = null;
|
||
try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {}
|
||
return enrichMovie(m, genreMap, fsk, i + 1);
|
||
}));
|
||
res.json([...top10, ...restRaw.map(m => enrichMovie(m, genreMap, null, null))]);
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
router.get('/upcoming', authenticate, async (req, res) => {
|
||
try {
|
||
const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming', 3), getGenreMap()]);
|
||
const filtered = filterMovies(pages).filter(m => m.release_date);
|
||
filtered.sort((a, b) => a.release_date.localeCompare(b.release_date));
|
||
const groups = {};
|
||
for (const m of filtered) {
|
||
const d = new Date(m.release_date);
|
||
const dow = d.getDay();
|
||
const mon = new Date(d);
|
||
mon.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow));
|
||
const key = mon.toISOString().slice(0, 10);
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(enrichMovie(m, genreMap, null, null));
|
||
}
|
||
res.json(groups);
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
router.get('/movie/:id', authenticate, async (req, res) => {
|
||
try {
|
||
const [detail, genreMap, extIds] = await Promise.all([
|
||
tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }),
|
||
getGenreMap(),
|
||
tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})),
|
||
]);
|
||
const fsk = extractFSK(detail.release_dates);
|
||
const imdbId = extIds.imdb_id || detail.imdb_id || null;
|
||
|
||
// xREL: 2-Schritt
|
||
let xrel = [];
|
||
try {
|
||
const extInfoId = await findExtInfoId(detail.title, detail.original_title);
|
||
if (extInfoId) xrel = await getGermanReleasesById(extInfoId);
|
||
} catch (_) {}
|
||
|
||
res.json({
|
||
id: detail.id,
|
||
title: detail.title || detail.original_title,
|
||
original_title: detail.original_title,
|
||
tagline: detail.tagline,
|
||
overview: detail.overview,
|
||
poster_path: detail.poster_path,
|
||
backdrop_path: detail.backdrop_path,
|
||
release_date: detail.release_date,
|
||
runtime: detail.runtime,
|
||
vote_average: detail.vote_average,
|
||
genres: (detail.genres || []).map(g => g.name),
|
||
fsk,
|
||
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
|
||
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
|
||
tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`,
|
||
imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null,
|
||
xrel,
|
||
has_xrel: xrel.length > 0,
|
||
});
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// POST /api/tools/media/xrel-check – Cover-Badge Batch-Check
|
||
router.post('/xrel-check', authenticate, async (req, res) => {
|
||
const movies = (req.body.movies || []).slice(0, 20);
|
||
if (!movies.length) return res.json({});
|
||
|
||
const result = {};
|
||
// 2er-Batches (2 API-Calls pro Film: search + releases)
|
||
for (let i = 0; i < movies.length; i += 2) {
|
||
const chunk = movies.slice(i, i + 2);
|
||
await Promise.all(chunk.map(async m => {
|
||
try {
|
||
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;
|
||
}
|
||
}));
|
||
if (i + 2 < movies.length) await new Promise(r => setTimeout(r, 300));
|
||
}
|
||
res.json(result);
|
||
});
|
||
|
||
router.get('/favorites', authenticate, (req, res) => {
|
||
const rows = db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC').all(req.user.id);
|
||
res.json(rows);
|
||
});
|
||
|
||
router.post('/favorites', authenticate, (req, res) => {
|
||
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' });
|
||
try {
|
||
db.prepare(`INSERT OR IGNORE INTO movie_favorites (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 });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
|
||
db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id));
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
||
res.json({ configured: !!getToken() });
|
||
});
|
||
|
||
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
||
const { tmdb_token } = req.body;
|
||
if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' });
|
||
db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim());
|
||
genreCache = null;
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
module.exports = router;
|
||
|
||
// DEBUG – temporär, nach Test entfernen
|
||
router.get('/debug-xrel', authenticate, async (req, res) => {
|
||
const title = req.query.title || 'Mandalorian';
|
||
const log = [];
|
||
try {
|
||
// Step 1: ext_info search
|
||
const searchUrl = `${XREL_BASE}/search/ext_info.json?q=${encodeURIComponent(title)}&type=movie&limit=5`;
|
||
log.push({ step: 'search_url', url: searchUrl });
|
||
const searchRes = await fetch(searchUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(6000) });
|
||
log.push({ step: 'search_status', status: searchRes.status });
|
||
const searchData = await searchRes.json();
|
||
log.push({ step: 'search_response', data: searchData });
|
||
|
||
if (searchData.results?.length) {
|
||
const extInfoId = searchData.results[0].id;
|
||
log.push({ step: 'ext_info_id', id: extInfoId });
|
||
|
||
// Step 2a: Scene releases
|
||
const sceneUrl = `${XREL_BASE}/release/ext_info.json?id=${extInfoId}&per_page=5`;
|
||
const sceneRes = await fetch(sceneUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(6000) });
|
||
const sceneData = await sceneRes.json();
|
||
log.push({ step: 'scene_releases', status: sceneRes.status, keys: Object.keys(sceneData), sample: JSON.stringify(sceneData).slice(0, 500) });
|
||
|
||
// Step 2b: P2P releases
|
||
const p2pUrl = `${XREL_BASE}/p2p/releases.json?ext_info_id=${extInfoId}&per_page=5`;
|
||
const p2pRes = await fetch(p2pUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(6000) });
|
||
const p2pData = await p2pRes.json();
|
||
log.push({ step: 'p2p_releases', status: p2pRes.status, keys: Object.keys(p2pData), sample: JSON.stringify(p2pData).slice(0, 500) });
|
||
}
|
||
} catch (e) {
|
||
log.push({ step: 'error', message: e.message });
|
||
}
|
||
res.json(log);
|
||
});
|
||
module.exports = router;
|