1195 lines
52 KiB
JavaScript
1195 lines
52 KiB
JavaScript
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;
|