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

504 lines
21 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;
// Umlaut-Normalisierung für xREL-Suche (Nürnberg → Nuernberg etc.)
function normalizeUmlauts(s) {
return (s||'')
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue')
.replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue')
.replace(/ß/g,'ss');
}
// ── 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;
}
const RELEASE_TYPE_LABEL = { 2:'Kino (limited)', 3:'Kinostart', 4:'Web', 5:'Retail', 6:'TV' };
function extractReleaseDates(rd) {
if (!rd?.results) return [];
// Nur DE und US, Typen 2-5 (kein Premiere=1, kein TV=6)
const entries = [];
for (const country of ['DE', 'US']) {
const c = rd.results.find(r => r.iso_3166_1 === country);
if (!c) continue;
for (const r of c.release_dates) {
if (!r.release_date) continue;
const label = RELEASE_TYPE_LABEL[r.type];
if (!label) continue;
entries.push({
country,
label: `${label} (${country})`,
date: r.release_date.slice(0, 10),
});
}
}
entries.sort((a, b) => a.date.localeCompare(b.date));
return entries;
}
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 XREL_DATE_LABEL = {
'de-cine': 'Kinostart (DE)',
'us-cine': 'Kinostart (US)',
'de-web': 'Web (DE)',
'us-web': 'Web (US)',
'de-retail': 'Retail (DE)',
'us-retail': 'Retail (US)',
'de-hd': 'Blu-ray (DE)',
'us-hd': 'Blu-ray (US)',
'de-rental': 'Rental (DE)',
'us-rental': 'Rental (US)',
'r5': 'R5',
};
const QUALITY_BLOCKLIST = r => /\b(2160p|4k|uhd|uhdbluray|uhdbd|remux)\b/i.test(r) || /COMPLETE[._](?:BLURAY|BD)/i.test(r);
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, movieYear) {
const cacheKey = `xrel:tmdb:${tmdbId}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase();
// Suchqueries: original, deutsch, und normalisierte Varianten
// Wichtig: xREL nutzt oft den englischen Originaltitel auch bei deutschen Filmen
const rawQueries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i);
// Normalisierte Versionen (Umlaute) als zusätzliche Queries
const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim())
.filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q));
const allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i);
// Alle parallel suchen
const searchResults = await Promise.all(allQueries.map(async q => {
try {
return {
q,
data: await xrelFetch('/search/ext_info.json', {
q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:'movie', limit:10
})
};
} catch(_) { return null; }
}));
let extInfoId = null;
// Strategie 1: IMDb-URI exakter Match (zuverlässigste Methode)
if (imdbId) {
for (const r of searchResults) {
if (!r) continue;
const hit = (r.data?.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`));
if (hit?.id) { extInfoId = hit.id; break; }
}
}
// Strategie 2: Titel/alt_title Match gegen alle Queries (normalisiert)
if (!extInfoId) {
const allCleanQueries = allQueries.map(q => clean(q));
for (const r of searchResults) {
if (!r?.data?.results) continue;
for (const h of r.data.results) {
if (allCleanQueries.includes(clean(h.title)) || allCleanQueries.includes(clean(h.alt_title))) {
extInfoId = h.id;
break;
}
}
if (extInfoId) break;
}
}
if (!extInfoId) {
cacheSet(cacheKey, [], 60*60*1000);
return [];
}
// Releases + ext_info Details parallel holen
const [sceneData, p2pData, infoData] = 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(()=>({})),
xrelFetch('/ext_info/info.json', { id: extInfoId, type: 'movie' }).catch(()=>({})),
]);
// xREL Erscheinungsdaten
const xrel_dates = (infoData.release_dates||[])
.filter(d => XREL_DATE_LABEL[d.type])
.map(d => ({ label: XREL_DATE_LABEL[d.type], date: d.date }))
.sort((a,b) => a.date.localeCompare(b.date));
const results = [];
// Jahresfilter: nur für Filme ab 2015 anwenden
// Alte Filme (< 2015) haben oft Releases die Jahre später erschienen → nicht filtern
const releaseYearOk = (releaseDate) => {
if (!movieYear || !releaseDate) return true;
if (movieYear < 2015) return true; // alte Filme: alles erlaubt
const y = parseInt(releaseDate.slice(0,4));
return Math.abs(y - movieYear) <= 3;
};
for (const r of sceneData.list||[]) {
if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue;
if (QUALITY_BLOCKLIST(r.dirname||'')) continue;
const date = r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null;
if (!releaseYearOk(date)) continue;
results.push({
dirname: r.dirname, size: r.size ? `${r.size.number} ${r.size.unit}` : null,
category: 'Scene', url: r.link_href, date, p2p: false,
});
}
for (const r of p2pData.list||[]) {
if (r.main_lang !== 'german') continue;
if (QUALITY_BLOCKLIST(r.dirname||'')) continue;
const date = r.pub_time ? new Date(r.pub_time*1000).toISOString().slice(0,10) : null;
if (!releaseYearOk(date)) 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, p2p: true,
});
}
results.sort((a,b) => (b.date||'').localeCompare(a.date||''));
const top5 = results.slice(0, 5);
const payload = { releases: top5, xrel_dates };
cacheSet(cacheKey, payload, top5.length > 0 || xrel_dates.length > 0 ? 12*60*60*1000 : 60*60*1000);
return payload;
}
// ── 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 }); }
});
// GET /api/tools/media/search?q=TITEL Filmsuche mit Wildcard-Support
// hulk*2008 → sucht "hulk" mit year=2008
router.get('/search', authenticate, async (req, res) => {
try {
let q = (req.query.q || '').trim();
if (!q || q.length < 2) return res.json([]);
// Wildcard: z.B. "hulk*2008" → query="hulk", year="2008"
let yearFilter = null;
const wildcardMatch = q.match(/^(.+?)\*(\d{4})$/);
if (wildcardMatch) {
q = wildcardMatch[1].trim();
yearFilter = wildcardMatch[2];
}
const searchParams = { query: q, include_adult: 'false' };
if (yearFilter) searchParams.year = yearFilter;
const [results, genreMap] = await Promise.all([
tmdb('/search/movie', searchParams),
getGenreMap(),
]);
const seen = new Set();
const filtered = (results.results || [])
.filter(m => {
if (BLOCKED_LANGS.has(m.original_language)) return false;
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
})
.slice(0, 20)
.map(m => enrichMovie(m, genreMap, null, null));
// Neueste zuerst (Backend sortiert, Frontend sortiert auch konsistent)
filtered.sort((a,b) => (b.release_date||'').localeCompare(a.release_date||''));
res.json(filtered);
} 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 on-demand ?refresh=1 ignoriert Cache, ?imdb_id=tt...&title=...&orig=... vermeidet TMDb-Calls
router.get('/movie/:id/xrel', authenticate, async (req, res) => {
try {
const cacheKey = `xrel:tmdb:${req.params.id}`;
const forceRefresh = req.query.refresh === '1';
// Cache prüfen: befüllte Einträge immer zurückgeben
// Bei refresh=1 oder leerem Cache: xREL aufrufen
if (!forceRefresh) {
const cached = cacheGet(cacheKey);
if (cached !== undefined) {
const releases = Array.isArray(cached) ? cached : (cached.releases || []);
const xrel_dates = Array.isArray(cached) ? [] : (cached.xrel_dates || []);
if (releases.length > 0 || xrel_dates.length > 0) {
return res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 });
}
}
}
// Cache löschen bei refresh (damit neu gecacht wird)
if (forceRefresh) {
db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey);
}
// Immer xREL aufrufen wenn kein befüllter Cache
// Titel + IMDb-ID aus Query-Params wenn vorhanden (spart 2 TMDb-Calls)
let imdbId = req.query.imdb_id || null;
let title = req.query.title || null;
let origTitle = req.query.orig || null;
if (!title) {
const [detail, extIds] = await Promise.all([
tmdb(`/movie/${req.params.id}`, { append_to_response: '' }).catch(()=>({})),
tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})),
]);
imdbId = extIds.imdb_id || null;
title = detail.title || null;
origTitle = detail.original_title || null;
}
// Filmjahr aus Query-Param; year=0 oder fehlt → kein Jahresfilter
let filmYear = null;
if (req.query.year && req.query.year !== '0') filmYear = parseInt(req.query.year);
const data = await getXrelData(req.params.id, imdbId, title, origTitle, filmYear);
const releases = Array.isArray(data) ? data : (data.releases || []);
const xrel_dates = Array.isArray(data) ? [] : (data.xrel_dates || []);
res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// GET /api/tools/media/xrel-cached?ids=1,2,3 welche Filme haben Releases im Cache
router.get('/xrel-cached', authenticate, (req, res) => {
const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean);
if (!ids.length) return res.json({});
const result = {};
for (const id of ids) {
const cached = cacheGet(`xrel:tmdb:${id}`);
result[id] = cached !== undefined && (Array.isArray(cached) ? cached.length > 0 : cached.releases?.length > 0);
}
res.json(result);
});
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 });
});
// DEBUG ext_info nach Test entfernen
router.get('/debug-extinfo', authenticate, async (req, res) => {
const id = req.query.id || '';
if (!id) return res.json({ error: 'id required' });
try {
const data = await xrelFetch('/ext_info/info.json', { id, type: 'movie' });
res.json(data);
} catch(e) { res.status(500).json({ error: e.message }); }
});
// DEBUG nach Test entfernen
router.get('/debug-xrel3', authenticate, async (req, res) => {
const title = req.query.title || '';
const orig = req.query.orig || '';
const imdb_id = req.query.imdb_id || null;
const log = { title, orig, imdb_id };
try {
const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase();
const rawQueries = [orig, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i);
const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim())
.filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q));
log.rawQueries = rawQueries;
log.normQueries = normQueries;
log.allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i);
const results = [];
for (const q of log.allQueries) {
try {
const data = await xrelFetch('/search/ext_info.json', {
q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:'movie', limit:5
});
results.push({
query: normalizeUmlauts(q).replace(/[*#!?:]/g,''),
hits: (data.results||[]).map(h => ({ id: h.id, title: h.title, alt_title: h.alt_title, uris: h.uris }))
});
} catch(e) { results.push({ query: q, error: e.message }); }
}
log.searchResults = results;
} catch(e) { log.error = e.message; }
res.json(log);
});
module.exports = router;