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

913 lines
39 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, mediaType='movie') {
const cacheKey = `xrel:${mediaType}:${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:mediaType, 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: mediaType }).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;
if (mediaType === 'tv') return true; // TV-Serien: kein Jahresfilter (Staffeln erscheinen über Jahre)
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 [movieResults, tvResults, genreMap, tvGenreMap] = await Promise.all([
tmdb('/search/movie', searchParams),
tmdb('/search/tv', { query: q, include_adult: 'false', ...(yearFilter ? { first_air_date_year: yearFilter } : {}) }),
getGenreMap(),
tmdb('/genre/tv/list').catch(()=>({genres:[]})),
]);
const tvGenres = {};
(tvGenreMap.genres||[]).forEach(g => { tvGenres[g.id] = g.name; });
const seen = new Set();
const movies = (movieResults.results || [])
.filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; if (seen.has('m'+m.id)) return false; seen.add('m'+m.id); return true; })
.map(m => ({ ...enrichMovie(m, genreMap, null, null), media_type: 'movie' }));
const tvShows = (tvResults.results || [])
.filter(m => { if (seen.has('t'+m.id)) return false; seen.add('t'+m.id); return true; })
.map(m => ({
id: m.id, media_type: 'tv',
title: m.name || m.original_name,
original_title: m.original_name,
release_date: m.first_air_date || '',
poster_path: m.poster_path || '',
genres: (m.genre_ids||[]).map(id => tvGenres[id] || genreMap[id]).filter(Boolean),
vote_average: m.vote_average,
original_language: m.original_language,
origin_country: m.origin_country || [],
}));
// Sortierung: mit Datum zuerst (neueste), ohne Datum danach
const combined = [...movies, ...tvShows]
.sort((a,b) => {
if (!a.release_date && !b.release_date) return 0;
if (!a.release_date) return 1;
if (!b.release_date) return -1;
return b.release_date.localeCompare(a.release_date);
})
.slice(0, 30);
res.json(combined);
} catch(e) { res.status(500).json({ error: e.message }); }
});
// TV-Serie Detail
router.get('/tv/:id', authenticate, async (req, res) => {
try {
const [detail, genreMap, extIds] = await Promise.all([
tmdb(`/tv/${req.params.id}`, { append_to_response: 'credits,content_ratings' }),
getGenreMap(),
tmdb(`/tv/${req.params.id}/external_ids`).catch(()=>({})),
]);
// FSK aus content_ratings (DE)
let fsk = '';
const deRating = (detail.content_ratings?.results || []).find(r => r.iso_3166_1 === 'DE');
if (deRating?.rating) fsk = deRating.rating;
res.json({
id: detail.id, media_type: 'tv',
title: detail.name || detail.original_name,
original_title: detail.original_name,
tagline: detail.tagline || '',
overview: detail.overview,
poster_path: detail.poster_path,
backdrop_path: detail.backdrop_path,
release_date: detail.first_air_date || '',
runtime: detail.episode_run_time?.[0] || null,
vote_average: detail.vote_average,
genres: (detail.genres||[]).map(g=>g.name),
fsk,
cast: (detail.credits?.cast||[]).slice(0,8).map(c => c.name),
countries: (detail.production_countries||[]).map(c => c.name),
imdb_id: extIds.imdb_id || '',
number_of_seasons: detail.number_of_seasons,
number_of_episodes: detail.number_of_episodes,
status: detail.status,
});
} 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 xrelMediaType = req.query.media_type || 'movie';
const cacheKey = `xrel:${xrelMediaType}:${req.params.id}`;
const forceRefresh = req.query.refresh === '1';
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 });
}
}
}
if (forceRefresh) {
db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey);
// Auch alten Key löschen
db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(`xrel:tmdb:${req.params.id}`);
}
let imdbId = req.query.imdb_id || null;
let title = req.query.title || null;
let origTitle = req.query.orig || null;
if (!title) {
const endpoint = xrelMediaType === 'tv' ? 'tv' : 'movie';
const [detail, extIds] = await Promise.all([
tmdb(`/${endpoint}/${req.params.id}`, { append_to_response: '' }).catch(()=>({})),
tmdb(`/${endpoint}/${req.params.id}/external_ids`).catch(()=>({})),
]);
imdbId = extIds.imdb_id || null;
title = detail.name || detail.title || null;
origTitle = detail.original_name || detail.original_title || null;
}
let filmYear = null;
if (req.query.year && req.query.year !== '0' && xrelMediaType !== 'tv') filmYear = parseInt(req.query.year);
const data = await getXrelData(req.params.id, imdbId, title, origTitle, filmYear, xrelMediaType);
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:movie:${id}`) || cacheGet(`xrel:tv:${id}`) || cacheGet(`xrel:tmdb:${id}`);
result[id] = cached !== undefined && (Array.isArray(cached) ? cached.length > 0 : cached.releases?.length > 0);
}
res.json(result);
});
// ── Streaming Top 10 pro Anbieter ──────────────────────────────────────────────
// TMDb Watch-Provider-IDs für Deutschland (watch_region=DE)
// HINWEIS: Eine frühere Version hat hier versucht die offizielle Netflix-XLSX-Datei
// (komplette Historie seit 2021, alle ~90 Länder) direkt zu laden und zu parsen.
// Das hat den Container überlastet (CPU ~90%, RAM voll, Container kurzzeitig nicht
// erreichbar) die Datei ist für ein synchrones Parsing im selben Prozess zu groß.
// Netflix läuft daher wie alle anderen Anbieter über die leichtgewichtige
// TMDb-Näherung weiter unten.
const STREAMING_PROVIDERS = [
{ id: 'netflix', label: 'Netflix', tmdbId: '8' },
{ id: 'disney', label: 'Disney+', tmdbId: '337' },
{ id: 'prime', label: 'Amazon Prime Video', tmdbId: '9|119' },
{ id: 'appletv', label: 'Apple TV+', tmdbId: '350' },
{ id: 'paramount', label: 'Paramount+', tmdbId: '531' },
{ id: 'wow', label: 'WOW (Sky)', tmdbId: '30' },
{ id: 'rtlplus', label: 'RTL+', tmdbId: '421' },
];
// Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen
// MUSS vor /streaming/:providerId stehen, sonst matched Express "all-providers" als providerId!
router.get('/streaming/all-providers', authenticate, requireAdmin, async (req, res) => {
try {
const [movieProv, tvProv] = await Promise.all([
tmdb('/watch/providers/movie', { watch_region: 'DE' }),
tmdb('/watch/providers/tv', { watch_region: 'DE' }),
]);
const merged = {};
for (const p of [...(movieProv.results||[]), ...(tvProv.results||[])]) {
merged[p.provider_id] = { id: p.provider_id, name: p.provider_name, logo: p.logo_path,
priority: p.display_priorities?.DE ?? p.display_priority ?? 999 };
}
const list = Object.values(merged).sort((a,b) => a.priority - b.priority);
res.json(list);
} catch(e) { res.status(500).json({ error: e.message }); }
});
router.get('/streaming/providers', authenticate, (req, res) => {
res.json(STREAMING_PROVIDERS.map(p => ({ id: p.id, label: p.label })));
});
// GET /streaming/:providerId Top 10 Filme + Serien für einen Anbieter (DE)
router.get('/streaming/:providerId', authenticate, async (req, res) => {
try {
const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId);
if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' });
// Ergebnis 6h cachen die Trending+Provider-Checks machen mehrere
// zusätzliche TMDb-Calls, das muss nicht bei jedem Seitenaufruf neu laufen
const streamCacheKey = `streaming-top10:${provider.id}`;
const cachedResult = cacheGet(streamCacheKey);
if (cachedResult !== undefined) return res.json(cachedResult);
// Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1)
const minYear = new Date().getFullYear() - 1;
const minDate = `${minYear}-01-01`;
// ── Strategie 1: TMDb "trending diese Woche" + Provider-Check pro Titel ──
// /trending unterstützt KEINEN watch_provider-Filter, deshalb: erst die
// wöchentlich angesagten Titel holen (kleine Liste, ~20 Treffer), dann für
// jeden einzeln per /watch/providers prüfen ob er beim gewählten Anbieter
// in DE verfügbar ist. So tauchen frische virale Hits (z.B. ein Titel der
// gerade erst diese Woche durchstartet) sofort auf, statt erst Wochen
// später über die reine Gesamt-Popularität reinzurutschen.
async function getTrendingForProvider(mediaKind) {
try {
const path = mediaKind === 'movie' ? '/trending/movie/week' : '/trending/tv/week';
const trending = await tmdb(path, {});
const candidates = (trending.results || []).slice(0, 20);
const checked = await Promise.all(candidates.map(async item => {
try {
const provPath = mediaKind === 'movie'
? `/movie/${item.id}/watch/providers`
: `/tv/${item.id}/watch/providers`;
const provData = await tmdb(provPath, {});
const deFlat = provData.results?.DE?.flatrate || [];
const matchIds = provider.tmdbId.split('|').map(Number);
const available = deFlat.some(p => matchIds.includes(p.provider_id));
return available ? item : null;
} catch { return null; }
}));
return checked.filter(Boolean);
} catch { return []; } // Trending-Strategie ist nur eine Ergänzung bei Fehler einfach leer
}
// Höhere vote_count-Schwelle filtert Nischentitel jeder Herkunft heraus.
// Bei kleineren Katalogen (z.B. RTL+) reicht das oft nicht für 10 Treffer
// dann wird mit niedrigerer Schwelle nachgeladen (siehe fetchWithFallback unten).
const MIN_VOTES = 300;
const FALLBACK_MIN_VOTES = 50;
// Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt
// erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen
// gilt ein Deckel von 2 Plätzen in den Top 10 AUSSER ein Titel hat eine
// sehr hohe vote_count (= ist ein echter, in DE bekannter Crossover-Hit wie
// Squid Game), dann zählt er nicht gegen den Deckel.
const UNLIMITED_LANGS = new Set(['de', 'en']);
const MAX_PER_OTHER_LANGUAGE = 2;
const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug"
function buildParams(mediaKind, minVotes) {
return {
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc',
'vote_count.gte': minVotes,
...(mediaKind === 'movie'
? { 'primary_release_date.gte': minDate }
: { 'first_air_date.gte': minDate }),
};
}
async function fetchThreePages(mediaKind, minVotes) {
const params = buildParams(mediaKind, minVotes);
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const [p1, p2, p3] = await Promise.all([
tmdb(path, { ...params, page: 1 }),
tmdb(path, { ...params, page: 2 }),
tmdb(path, { ...params, page: 3 }),
]);
return [...(p1.results||[]), ...(p2.results||[]), ...(p3.results||[])];
}
// Erst Trending+Provider-Check (frische Hits), dann Discover mit hoher
// Schwelle, bei zu wenig Ergebnissen mit niedrigerer Schwelle nachladen
async function fetchWithFallback(mediaKind) {
const trendingHits = await getTrendingForProvider(mediaKind);
let raw = await fetchThreePages(mediaKind, MIN_VOTES);
let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language));
// Trending-Treffer vorne einfügen (Duplikate vermeiden)
const seenIds = new Set(filtered.map(m => m.id));
const merged = [];
for (const t of trendingHits) {
if (!seenIds.has(t.id) && !BLOCKED_LANGS.has(t.original_language)) {
merged.push(t);
seenIds.add(t.id);
}
}
merged.push(...filtered);
filtered = merged;
if (filtered.length < 10) {
const fallback = await fetchThreePages(mediaKind, FALLBACK_MIN_VOTES);
for (const m of fallback) {
if (!BLOCKED_LANGS.has(m.original_language) && !seenIds.has(m.id)) {
filtered.push(m);
seenIds.add(m.id);
}
}
}
return filtered;
}
const [movieRaw, tvRaw, genreMap] = await Promise.all([
fetchWithFallback('movie'),
fetchWithFallback('tv'),
getGenreMap(),
]);
// Liste bleibt nach Popularität sortiert. Deutsch/Englisch immer erlaubt.
// Andere Sprachen: max. 2 Plätze, außer der Titel ist ein nachweislicher
// Crossover-Hit (sehr hohe vote_count) der zählt dann nicht gegen den Deckel.
function applyLanguageWeighting(rawList) {
const otherLangCount = {};
const picked = [];
const overflow = [];
for (const item of rawList) {
const lang = item.original_language || 'xx';
const isUnlimited = UNLIMITED_LANGS.has(lang);
const isCrossoverHit = (item.vote_count || 0) >= CROSSOVER_HIT_VOTE_THRESHOLD;
if (isUnlimited || isCrossoverHit) {
picked.push(item);
} else if ((otherLangCount[lang] || 0) < MAX_PER_OTHER_LANGUAGE) {
picked.push(item);
otherLangCount[lang] = (otherLangCount[lang] || 0) + 1;
} else {
overflow.push(item);
}
if (picked.length >= 10) break;
}
for (const item of overflow) {
if (picked.length >= 10) break;
picked.push(item);
}
return picked.slice(0, 10);
}
const movies = applyLanguageWeighting(movieRaw)
.map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' }));
const tvShows = applyLanguageWeighting(tvRaw)
.map((m, i) => ({
id: m.id, media_type: 'tv', rank: i + 1,
title: m.name || m.original_name,
original_title: m.original_name,
release_date: m.first_air_date || '',
poster_path: m.poster_path || '',
genres: (m.genre_ids||[]).map(gid => genreMap[gid]).filter(Boolean),
vote_average: m.vote_average,
original_language: m.original_language,
origin_country: m.origin_country || [],
}));
const result = {
provider: { id: provider.id, label: provider.label },
movies, tvShows,
filterInfo: {
minYear,
minVotes: MIN_VOTES,
maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE,
},
};
cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
// ── Favorites Routen (feste VOR :param) ──────────────────────────────────────
// GET /favorites eigene Favoriten
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));
});
// GET /favorites/all Admin: alle User gruppiert
router.get('/favorites/all', authenticate, requireAdmin, (req, res) => {
const favs = db.prepare(`
SELECT f.*, u.username
FROM movie_favorites f
JOIN users u ON u.id = f.user_id
ORDER BY u.username ASC, f.added_at DESC
`).all();
res.json(favs);
});
// POST /favorites/user-notify User hat quittierte Favoriten gesehen
router.post('/favorites/user-notify', authenticate, (req, res) => {
db.prepare('UPDATE movie_favorites SET user_notified=1 WHERE user_id=? AND acknowledged=1 AND user_notified=0').run(req.user.id);
res.json({ ok: true });
});
// GET /favorites/unacked unquittierte Zählung (Admin: neue Favoriten; User: quittierte aber unbestätigte)
router.get('/favorites/unacked', authenticate, (req, res) => {
const isAdmin = req.user.role === 'admin';
if (isAdmin) {
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get();
res.json({ count: count.n, isAdmin: true });
} else {
// User: Favoriten die vom Admin quittiert wurden aber User noch nicht gesehen hat
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE user_id=? AND acknowledged=1 AND user_notified=0').get(req.user.id);
res.json({ count: count.n, isAdmin: false });
}
});
// POST /favorites/acknowledge-all Admin: alle quittieren
router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => {
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run();
res.json({ ok: true });
});
// POST /favorites Favorit hinzufügen (alle User inkl. Admin bekommen Pushover)
router.post('/favorites', authenticate, (req, res) => {
const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk, media_type } = req.body;
if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' });
try {
const r = db.prepare(`INSERT OR IGNORE INTO movie_favorites
(user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk,media_type,added_at)
VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
.run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||'', media_type||'movie');
if (r.changes > 0) {
try {
// Admin-Pushover-Einstellungen aus pushover_settings Tabelle
const adminUser = db.prepare("SELECT id FROM users WHERE role='admin' LIMIT 1").get();
const poCfg = adminUser
? db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(adminUser.id)
: null;
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id);
if (poCfg?.app_token && poCfg?.user_key) {
fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: poCfg.app_token,
user: poCfg.user_key,
message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`,
title: '⭐ Neue Favoriten',
priority: 0,
}),
}).catch(() => {});
}
} catch {}
}
res.json({ ok: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// DELETE /favorites/:tmdb_id eigenen Favoriten löschen (jeder User)
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 });
});
// POST /favorites/:id/acknowledge Admin: einzelnen quittieren
router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => {
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(req.params.id);
res.json({ ok: true });
});
// Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen
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);
});
// Debug xREL für TV
router.get('/debug-xrel-tv', authenticate, async (req, res) => {
const { title='', orig='', imdb_id='', year='0', id='0' } = req.query;
try {
const clean = s => (s||'').toLowerCase().trim();
const allQueries = [orig, title].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i);
// 1. Suche
const searchResults = await Promise.all(allQueries.map(async q => {
try {
const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#!?:]/g,''), type:'tv', limit:10 });
return { q, results: data?.results||[], total: data?.total||0 };
} catch(e) { return { q, error: e.message }; }
}));
// 2. IMDb-Match
let extInfoId = null;
if (imdb_id) {
for (const r of searchResults) {
const hit = (r.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdb_id}`));
if (hit) { extInfoId = hit.id; break; }
}
}
// 3. Titel-Match
if (!extInfoId) {
for (const r of searchResults) {
for (const h of r.results||[]) {
if (clean(h.title)===clean(orig)||clean(h.title)===clean(title)||
clean(h.alt_title)===clean(orig)||clean(h.alt_title)===clean(title)) {
extInfoId = h.id; break;
}
}
if (extInfoId) break;
}
}
// 4. Releases wenn gefunden
let releases = [];
if (extInfoId) {
const [scene, p2p] = await Promise.all([
xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 25 }).catch(()=>({})),
xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 25 }).catch(()=>({})),
]);
releases = [
...(scene.list||[]).map(r=>({ src:'scene', dirname:r.dirname, german:/german|deutsch/i.test(r.dirname||'') })),
...(p2p.list||[]).map(r=>({ src:'p2p', dirname:r.dirname, german: r.main_lang==='german' })),
];
}
res.json({ searchResults, extInfoId, releases: releases.slice(0,20) });
} catch(e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;