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

1195 lines
52 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.
// HINWEIS: Ein Versuch, die Netflix-Tudum-HTML-Seite zu parsen, wurde wieder
// entfernt das echte Markup ist modernes React/Next.js mit CSS-in-JS
// (emotion-Klassen), keine saubere serverseitig gerenderte Tabelle wie
// angenommen. Das Regex-Parsing fand 0 Treffer und wäre zudem bei jedem
// Netflix-Frontend-Update erneut kaputt gegangen. Netflix läuft daher wie
// alle anderen Anbieter über die 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)
// GET /streaming/:providerId/new Neuerscheinungen (letzte 60 Tage, neueste zuerst)
// Zuverlässig für ALLE Anbieter, da nur release_date + watch_provider kombiniert wird
// keine Sprach-/Popularitäts-Heuristik nötig, die Reihenfolge ergibt sich einfach
// aus dem tatsächlichen Veröffentlichungsdatum.
router.get('/streaming/:providerId/new', 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' });
const cacheKey = `streaming-new:${provider.id}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) return res.json(cached);
const today = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
})();
function daysAgo(n) {
const d = new Date();
d.setDate(d.getDate() - n);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function baseParams(mediaKind, sinceDate) {
return {
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc',
...(mediaKind === 'movie'
? { 'primary_release_date.gte': sinceDate, 'primary_release_date.lte': today }
: { 'first_air_date.gte': sinceDate, 'first_air_date.lte': today }),
};
}
async function fetchWindow(mediaKind, sinceDate) {
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const [p1, p2] = await Promise.all([
tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 1 }),
tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 2 }),
]);
return [...(p1.results||[]), ...(p2.results||[])].filter(m => !BLOCKED_LANGS.has(m.original_language));
}
// Manche Anbieter (z.B. Disney+, RTL+) haben bei TMDb nur sehr wenige Titel
// mit korrekt erfasstem Veröffentlichungsdatum + Verfügbarkeit verknüpft.
// Statt einer fast leeren Liste wird das Zeitfenster INDIVIDUELL pro Anbieter
// und getrennt für Filme/Serien schrittweise vergrößert, bis mindestens 10
// Titel zusammenkommen der tatsächlich verwendete Zeitraum wird im
// Ergebnis ehrlich mitgegeben (kein versteckter Fake-"neu").
const WINDOW_STAGES = [21, 45, 90, 180, 365, 730];
const TARGET_COUNT = 10;
async function fetchCandidatesWithExpansion(mediaKind) {
let usedWindow = WINDOW_STAGES[0];
let result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[0]));
for (let i = 1; i < WINDOW_STAGES.length && result.length < TARGET_COUNT; i++) {
usedWindow = WINDOW_STAGES[i];
result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[i]));
}
return { items: result, usedWindow };
}
// Gleiche Sprachgewichtung wie bei Top 10: Deutsch/Englisch unbegrenzt,
// alle anderen Sprachen zusammen max. 2 Plätze
const UNLIMITED_LANGS = new Set(['de', 'en']);
const MAX_OTHER_LANGUAGE_TOTAL = 2;
function applyLanguageWeighting(rawList) {
let otherCount = 0;
const picked = [];
const overflow = [];
for (const item of rawList) {
const lang = item.original_language || 'xx';
if (UNLIMITED_LANGS.has(lang)) {
picked.push(item);
} else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) {
picked.push(item);
otherCount++;
} 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 [movieResult, tvResult, genreMap] = await Promise.all([
fetchCandidatesWithExpansion('movie'),
fetchCandidatesWithExpansion('tv'),
getGenreMap(),
]);
// Titel die bereits in der (gecachten) Top-10-Liste dieses Anbieters stehen
// aus den Neuerscheinungen ausschließen, damit beide Reiter sich nicht
// überschneiden, selbst wenn sich die Zeitfenster teilweise überlappen
const top10Cached = cacheGet(`streaming-top10:${provider.id}`);
const top10MovieIds = new Set((top10Cached?.movies || []).map(m => m.id));
const top10TvIds = new Set((top10Cached?.tvShows || []).map(m => m.id));
const movies = applyLanguageWeighting(movieResult.items.filter(m => !top10MovieIds.has(m.id)))
.map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' }));
const tvShows = applyLanguageWeighting(tvResult.items.filter(m => !top10TvIds.has(m.id)))
.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,
movieWindowDays: movieResult.usedWindow,
tvWindowDays: tvResult.usedWindow,
};
cacheSet(cacheKey, result, 6 * 60 * 60 * 1000);
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
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' });
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`;
// Deutsch & Englisch sind unbegrenzt erlaubt. Alle anderen Sprachen
// zusammen (Koreanisch, Japanisch, Spanisch, etc.) dürfen max. 2 Plätze
// in den Top 10 belegen KEINE Ausnahme mehr für "hohe vote_count",
// weil genau das die Liste mit populären Anime/K-Dramen geflutet hat.
const UNLIMITED_LANGS = new Set(['de', 'en']);
const MAX_OTHER_LANGUAGE_TOTAL = 2;
function buildParams(mediaKind, minVotes, dateFrom) {
return {
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc',
'vote_count.gte': minVotes,
...(dateFrom
? (mediaKind === 'movie' ? { 'primary_release_date.gte': dateFrom } : { 'first_air_date.gte': dateFrom })
: {}),
};
}
async function fetchPages(mediaKind, minVotes, pageCount, dateFrom) {
const params = buildParams(mediaKind, minVotes, dateFrom);
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const calls = [];
for (let p = 1; p <= pageCount; p++) calls.push(tmdb(path, { ...params, page: p }));
const pages = await Promise.all(calls);
return pages.flatMap(p => p.results || []);
}
// "Ganz frisch"-Fenster: letzte 90 Tage, sehr niedrige Vote-Schwelle.
// TMDb's popularity-Wert reagiert auf kurzfristige Trend-Spitzen (Suchanfragen,
// Watchlist-Einträge etc.), das geht in der riesigen 2-Jahres-Gesamtliste aber
// unter alten Dauerbrennern unter. Durch die enge Zeitspanne kommen aktuell
// virale Titel (z.B. ein Release dieser Woche) automatisch nach vorne
// ganz ohne zusätzliche Einzel-Abfragen pro Titel.
const freshDate = (() => {
const d = new Date();
d.setDate(d.getDate() - 90);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
})();
// Robuster Fallback in vier Stufen, garantiert genug Kandidaten:
// 1) hohe Schwelle (letzte 2 Jahre, bekannte Titel)
// 2) mittlere Schwelle (letzte 2 Jahre)
// 3) niedrige Schwelle (letzte 2 Jahre)
// 4) OHNE Jahresfilter (für kleinere Kataloge wie RTL+, die laut TMDb in den
// letzten 2 Jahren kaum neue Lizenzen bekommen haben die zeigen wir dann
// mit ihren ältesten verfügbaren beliebten Titeln statt einer leeren Liste)
async function fetchCandidates(mediaKind) {
// Schritt 1: ganz frische Titel (letzte 90 Tage, niedrige Schwelle)
// werden später vorne in die Liste gemischt, damit aktuelle Hits Vorrang haben
const freshRaw = await fetchPages(mediaKind, 5, 1, freshDate);
const fresh = freshRaw.filter(m => !BLOCKED_LANGS.has(m.original_language));
// Schritt 2: normaler Pool der letzten 2 Jahre, gestaffelte Vote-Schwelle
let raw = await fetchPages(mediaKind, 300, 2, minDate);
let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language));
if (filtered.length < 15) {
const more = await fetchPages(mediaKind, 50, 2, minDate);
const seen = new Set(filtered.map(m => m.id));
for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); }
}
if (filtered.length < 10) {
const more = await fetchPages(mediaKind, 0, 2, minDate);
const seen = new Set(filtered.map(m => m.id));
for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); }
}
// Schritt 3: kleine Kataloge (z.B. RTL+) TMDb hat dort oft kaum Titel
// der letzten 2 Jahre korrekt eingetragen, dann ganz ohne Jahresfilter
if (filtered.length < 10) {
const more = await fetchPages(mediaKind, 20, 2, null);
const seen = new Set(filtered.map(m => m.id));
for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); }
}
// Frische Titel vorne einmischen (Duplikate raus)
const seenIds = new Set();
const merged = [];
for (const m of fresh) { if (!seenIds.has(m.id)) { merged.push(m); seenIds.add(m.id); } }
for (const m of filtered) { if (!seenIds.has(m.id)) { merged.push(m); seenIds.add(m.id); } }
return merged;
}
const [movieRaw, tvRaw, genreMap] = await Promise.all([
fetchCandidates('movie'),
fetchCandidates('tv'),
getGenreMap(),
]);
// Liste bleibt nach Popularität sortiert. DE/EN unbegrenzt, alle anderen
// Sprachen zusammen maximal 2 Plätze in den Top 10.
function applyLanguageWeighting(rawList) {
let otherCount = 0;
const picked = [];
const overflow = [];
for (const item of rawList) {
const lang = item.original_language || 'xx';
if (UNLIMITED_LANGS.has(lang)) {
picked.push(item);
} else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) {
picked.push(item);
otherCount++;
} else {
overflow.push(item);
}
if (picked.length >= 10) break;
}
// Falls weniger als 10 zusammenkommen (z.B. sehr kleiner Katalog),
// mit den nächstbeliebtesten auffüllen auch über den Sprachdeckel hinaus
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,
isOfficialData: false,
filterInfo: { minYear, maxOtherLanguageTotal: MAX_OTHER_LANGUAGE_TOTAL },
};
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) => {
// Betroffene Favoriten vor dem Update holen (für Pushover-Benachrichtigungen)
const toAck = db.prepare('SELECT id, user_id, title FROM movie_favorites WHERE acknowledged=0').all();
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run();
res.json({ ok: true });
// Pushover an jeden betroffenen User (fire & forget, pro User gruppiert)
try {
// User mit Pushover-Konfiguration ermitteln
const userIds = [...new Set(toAck.map(f => f.user_id))];
for (const userId of userIds) {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!poCfg?.app_token || !poCfg?.user_key) continue;
const userFavs = toAck.filter(f => f.user_id === userId);
const message = userFavs.length === 1
? `"${userFavs[0].title}" wurde von einem Admin bestätigt.`
: `${userFavs.length} deiner Favoriten wurden von einem Admin bestätigt.`;
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,
title: '✅ Favoriten bestätigt',
message,
priority: 0,
}),
}).catch(() => {});
}
} catch {}
});
// 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) => {
const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(req.params.id);
if (!fav) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id);
res.json({ ok: true });
// Pushover an den User schicken falls eingerichtet (fire & forget)
try {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.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,
title: '✅ Favorit bestätigt',
message: `"${fav.title}" wurde von einem Admin bestätigt.`,
priority: 0,
}),
}).catch(() => {});
}
} catch {}
});
// Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen
// Admin: Streaming-Top10-Cache leeren (z.B. nach Logik-Änderungen)
// Debug: rohe TMDb-Treffer pro Anbieter/Schwelle prüfen
// Debug: Top10 vs Neuerscheinungen direkt vergleichen
router.get('/streaming/debug-compare/:providerId', authenticate, requireAdmin, async (req, res) => {
try {
// Cache umgehen, beide Endpoints intern nochmal frisch ausführen indem wir
// einfach die echten Routen über fetch auf uns selbst aufrufen würde Auth
// brauchen - stattdessen Cache direkt leeren und Original-Handler-Logik
// duplizieren ist zu viel Aufwand für Debug. Einfacher: aktuellen Cache-Inhalt
// (falls vorhanden) UND since-Werte direkt ausgeben.
const top10 = cacheGet(`streaming-top10:${req.params.providerId}`);
const newRel = cacheGet(`streaming-new:${req.params.providerId}`);
res.json({
top10_cached: !!top10,
new_cached: !!newRel,
top10_tv_titles: (top10?.tvShows || []).map(t => ({ id: t.id, title: t.title, release_date: t.release_date })),
new_tv_titles: (newRel?.tvShows || []).map(t => ({ id: t.id, title: t.title, release_date: t.release_date })),
new_since: newRel?.since,
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
// Debug: Neuerscheinungen-Discover-Query roh prüfen (ohne Filterung danach)
router.get('/streaming/debug-new/:providerId', authenticate, requireAdmin, 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' });
const since = (() => {
const d = new Date();
d.setDate(d.getDate() - 21);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
})();
const today = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
})();
async function raw(mediaKind, sortBy) {
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const params = {
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: sortBy,
...(mediaKind === 'movie'
? { 'primary_release_date.gte': since, 'primary_release_date.lte': today }
: { 'first_air_date.gte': since, 'first_air_date.lte': today }),
};
const data = await tmdb(path, { ...params, page: 1 });
return {
total_results: data.total_results,
sample: (data.results||[]).slice(0,10).map(m => ({
title: m.title||m.name, lang: m.original_language,
vote_count: m.vote_count, popularity: m.popularity,
date: m.release_date||m.first_air_date,
})),
};
}
const [movieByPop, movieByDate, tvByPop, tvByDate] = await Promise.all([
raw('movie', 'popularity.desc'),
raw('movie', 'primary_release_date.desc'),
raw('tv', 'popularity.desc'),
raw('tv', 'first_air_date.desc'),
]);
res.json({ provider: provider.label, since, today, movieByPop, movieByDate, tvByPop, tvByDate });
} catch(e) { res.status(500).json({ error: e.message }); }
});
router.get('/streaming/debug/:providerId', authenticate, requireAdmin, 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' });
const minYear = new Date().getFullYear() - 1;
const minDate = `${minYear}-01-01`;
async function countFor(mediaKind, minVotes) {
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const params = {
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 }),
};
const data = await tmdb(path, { ...params, page: 1 });
return { total_results: data.total_results, total_pages: data.total_pages, sample: (data.results||[]).slice(0,3).map(m=>m.title||m.name) };
}
// Auch OHNE Jahresfilter testen, um zu sehen ob der Jahresfilter das Problem ist
async function countNoYearFilter(mediaKind, minVotes) {
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const params = {
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc',
'vote_count.gte': minVotes,
};
const data = await tmdb(path, { ...params, page: 1 });
return { total_results: data.total_results, total_pages: data.total_pages };
}
const [movies300, movies50, movies0, moviesNoYear, tv300, tv50, tv0, tvNoYear] = await Promise.all([
countFor('movie', 300), countFor('movie', 50), countFor('movie', 0), countNoYearFilter('movie', 0),
countFor('tv', 300), countFor('tv', 50), countFor('tv', 0), countNoYearFilter('tv', 0),
]);
res.json({
provider: provider.label, tmdbId: provider.tmdbId, minDate,
movies: { withVotes300: movies300, withVotes50: movies50, withVotes0: movies0, noYearFilter: moviesNoYear },
tv: { withVotes300: tv300, withVotes50: tv50, withVotes0: tv0, noYearFilter: tvNoYear },
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
router.post('/streaming/clear-cache', authenticate, requireAdmin, (req, res) => {
const r = db.prepare("DELETE FROM xrel_cache WHERE cache_key LIKE 'streaming-top10:%' OR cache_key LIKE 'streaming-new:%'").run();
res.json({ ok: true, cleared: r.changes });
});
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;