Files
dickendock/backend/src/tools/media/routes.js

346 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;