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

308 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;
// Sprachen die wir NICHT wollen
const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']);
// Genre-IDs die wir nicht wollen (nur wenn das der EINZIGE Genre ist)
// 16=Animation, 99=Documentary aber nur rausfiltern wenn explizit kein Spielfilm-Genre dabei
const DOC_GENRE_ID = 99;
// ── SQLite Cache ──────────────────────────────────────────────────────────────
function cacheGet(key) {
const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key);
if (!row) return undefined;
if (Date.now() > row.expires_at) { db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); return undefined; }
try { return JSON.parse(row.value); } catch { return undefined; }
}
function cacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) {
db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key,value,expires_at) VALUES (?,?,?)')
.run(key, JSON.stringify(value), Date.now() + ttlMs);
}
// ── TMDb ──────────────────────────────────────────────────────────────────────
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 ${res.status}`);
return res.json();
}
let genreCache = null;
async function getGenreMap() {
if (genreCache) return genreCache;
const d = await tmdb('/genre/movie/list');
genreCache = {};
for (const g of d.genres) genreCache[g.id] = g.name;
return genreCache;
}
function extractFSK(rd) {
const de = rd?.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 (BLOCKED_LANGS.has(m.original_language)) 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);
// Reine Dokus rausfiltern
const genres = m.genre_ids || [];
if (genres.length === 1 && genres[0] === DOC_GENRE_ID) return false;
if (genres.length === 0 && m.vote_count < 5) return false;
return true;
});
}
function enrichMovie(m, genreMap, fsk, rank) {
return {
id: m.id, title: m.title||m.original_title, original_title: m.original_title,
original_language: m.original_language, poster_path: m.poster_path,
overview: m.overview, release_date: m.release_date, vote_average: m.vote_average,
genres: (m.genre_ids||[]).map(id => genreMap[id]).filter(Boolean),
fsk: fsk??null, rank: rank??null,
};
}
// ── xREL ─────────────────────────────────────────────────────────────────────
const QUALITY_BLOCKLIST = /\b(2160p|4k|uhd|uhdbluray|uhdbd|complete|bluray|blu-ray|remux|hevc|x265|h265)\b/i;
async function xrelFetch(path, params = {}) {
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(7000),
});
if (res.status === 429) {
await new Promise(r => setTimeout(r, (parseInt(res.headers.get('Retry-After')||'10'))*1000));
const r2 = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000) });
if (!r2.ok) throw new Error(`xREL ${r2.status}`);
return r2.json();
}
if (!res.ok) throw new Error(`xREL ${res.status}`);
return res.json();
}
// Einzige Funktion für xREL: benutzt immer die ext_info_id und gibt max 5 deutsche ≤1080p Releases
// Cache-Key: tmdb_id (immer eindeutig, konsistent zwischen Badge und Modal)
async function getXrelData(tmdbId, imdbId, title, originalTitle) {
const cacheKey = `xrel:tmdb:${tmdbId}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
// Alle Suchen parallel originaltitel und deutsch gleichzeitig
const clean = (s) => (s||'').replace(/[*#!?:]/g,'').trim().toLowerCase();
const queries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i);
const searchResults = await Promise.all(queries.map(async q => {
try {
return await xrelFetch('/search/ext_info.json', {
q: q.replace(/[*#!?:]/g,''), type:'movie', limit:10
});
} catch(_) { return null; }
}));
let extInfoId = null;
// IMDb-URI Match zuerst
if (imdbId) {
for (const data of searchResults) {
const hit = (data?.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`));
if (hit?.id) { extInfoId = hit.id; break; }
}
}
// Exakter Titel-Match als Fallback
if (!extInfoId) {
for (const data of searchResults) {
const hit = (data?.results||[]).find(h => clean(h.title)===clean(queries[0]) || clean(h.alt_title)===clean(queries[0]));
if (hit?.id) { extInfoId = hit.id; break; }
}
}
if (!extInfoId) {
cacheSet(cacheKey, [], 60*60*1000); // 1h für nicht gefunden
return [];
}
// Releases holen: Scene + P2P parallel, nur german, nur ≤1080p, max 5
const [sceneData, p2pData] = await Promise.all([
xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 }).catch(()=>({})),
xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 50 }).catch(()=>({})),
]);
const results = [];
for (const r of sceneData.list||[]) {
if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue;
if (QUALITY_BLOCKLIST.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,
date: r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null, p2p: false,
});
}
for (const r of p2pData.list||[]) {
if (r.main_lang !== 'german') continue;
if (QUALITY_BLOCKLIST.test(r.dirname||'')) 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 top5 = results.slice(0, 5);
cacheSet(cacheKey, top5, top5.length > 0 ? 12*60*60*1000 : 60*60*1000);
return top5;
}
// ── 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), restRaw = filtered.slice(10);
const top10 = await Promise.all(top10raw.map(async (m,i) => {
let fsk = null;
try { fsk = extractFSK(await tmdb(`/movie/${m.id}/release_dates`)); } 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), 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 }); }
});
// Modal: TMDb-Daten sofort, xREL via separaten Endpoint
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 || null;
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,
imdb_id: imdbId,
countries: (detail.production_countries||[]).map(c => c.name),
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
// xREL separat lädt im Hintergrund, cached per tmdb_id
router.get('/movie/:id/xrel', authenticate, async (req, res) => {
try {
const cacheKey = `xrel:tmdb:${req.params.id}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) return res.json({ xrel: cached, has_xrel: cached.length > 0 });
// Brauchen Titel + IMDb-ID
const [detail, extIds] = await Promise.all([
tmdb(`/movie/${req.params.id}`, { append_to_response: '' }).catch(()=>({})),
tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})),
]);
const imdbId = extIds.imdb_id || null;
const xrel = await getXrelData(req.params.id, imdbId, detail.title, detail.original_title);
res.json({ xrel, has_xrel: xrel.length > 0 });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// Badge-Check: benutzt denselben Cache wie Modal
router.post('/xrel-check', authenticate, async (req, res) => {
const movies = (req.body.movies||[]).slice(0,1);
if (!movies.length) return res.json({});
const m = movies[0];
const cacheKey = `xrel:tmdb:${m.id}`;
try {
const cached = cacheGet(cacheKey);
if (cached !== undefined) return res.json({ [m.id]: cached.length > 0 });
const xrel = await getXrelData(m.id, m.imdb_id||null, m.title, m.original_title);
res.json({ [m.id]: xrel.length > 0 });
} catch(_) { res.json({ [m.id]: false }); }
});
router.get('/favorites', authenticate, (req, res) => {
res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id));
});
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 });
});
router.delete('/xrel-cache', authenticate, requireAdmin, (req, res) => {
res.json({ deleted: db.prepare('DELETE FROM xrel_cache').run().changes });
});
module.exports = router;