const express = require('express'); const router = express.Router(); const db = require('../../db'); const { authenticate, requireAdmin } = require('../../middleware/auth'); const TMDB_BASE = 'https://api.themoviedb.org/3'; const XREL_BASE = 'https://api.xrel.to/v2'; const THIS_YEAR = new Date().getFullYear(); const MIN_YEAR = THIS_YEAR - 1; // Sprachen die wir NICHT wollen const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']); // Genre-IDs die wir nicht wollen (nur wenn das der EINZIGE Genre ist) // 16=Animation, 99=Documentary – aber nur rausfiltern wenn explizit kein Spielfilm-Genre dabei const DOC_GENRE_ID = 99; // Umlaut-Normalisierung für xREL-Suche (Nürnberg → Nuernberg etc.) function normalizeUmlauts(s) { return (s||'') .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue') .replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue') .replace(/ß/g,'ss'); } // ── SQLite Cache ────────────────────────────────────────────────────────────── function cacheGet(key) { const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key); if (!row) return undefined; if (Date.now() > row.expires_at) { db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); return undefined; } try { return JSON.parse(row.value); } catch { return undefined; } } function cacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) { db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key,value,expires_at) VALUES (?,?,?)') .run(key, JSON.stringify(value), Date.now() + ttlMs); } // ── TMDb ────────────────────────────────────────────────────────────────────── function getToken() { return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; } async function tmdb(path, params = {}) { const token = getToken(); if (!token) throw new Error('Kein TMDb API-Token konfiguriert'); const url = new URL(TMDB_BASE + path); url.searchParams.set('language', 'de-DE'); url.searchParams.set('region', 'DE'); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); if (!res.ok) throw new Error(`TMDb ${res.status}`); return res.json(); } let genreCache = null; async function getGenreMap() { if (genreCache) return genreCache; const d = await tmdb('/genre/movie/list'); genreCache = {}; for (const g of d.genres) genreCache[g.id] = g.name; return genreCache; } function extractFSK(rd) { const de = rd?.results?.find(r => r.iso_3166_1 === 'DE'); if (!de?.release_dates?.length) return null; const sorted = [...de.release_dates].sort((a, b) => a.type === 3 ? -1 : 1); for (const r of sorted) { const c = (r.certification||'').trim(); if (c) return c; } return null; } const RELEASE_TYPE_LABEL = { 2:'Kino (limited)', 3:'Kinostart', 4:'Web', 5:'Retail', 6:'TV' }; function extractReleaseDates(rd) { if (!rd?.results) return []; // Nur DE und US, Typen 2-5 (kein Premiere=1, kein TV=6) const entries = []; for (const country of ['DE', 'US']) { const c = rd.results.find(r => r.iso_3166_1 === country); if (!c) continue; for (const r of c.release_dates) { if (!r.release_date) continue; const label = RELEASE_TYPE_LABEL[r.type]; if (!label) continue; entries.push({ country, label: `${label} (${country})`, date: r.release_date.slice(0, 10), }); } } entries.sort((a, b) => a.date.localeCompare(b.date)); return entries; } async function fetchAllPages(path, maxPages = 3) { const first = await tmdb(path, { page: 1 }); const total = Math.min(first.total_pages || 1, maxPages); let results = [...first.results]; if (total > 1) { const extra = await Promise.all(Array.from({ length: total-1 }, (_,i) => tmdb(path, { page: i+2 }))); for (const p of extra) results = results.concat(p.results); } return results; } function filterMovies(list) { const seen = new Set(); return list.filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; const y = m.release_date ? parseInt(m.release_date.slice(0,4)) : 0; if (y > 0 && y < MIN_YEAR) return false; if (seen.has(m.id)) return false; seen.add(m.id); // Reine Dokus rausfiltern const genres = m.genre_ids || []; if (genres.length === 1 && genres[0] === DOC_GENRE_ID) return false; if (genres.length === 0 && m.vote_count < 5) return false; return true; }); } function enrichMovie(m, genreMap, fsk, rank) { return { id: m.id, title: m.title||m.original_title, original_title: m.original_title, original_language: m.original_language, poster_path: m.poster_path, overview: m.overview, release_date: m.release_date, vote_average: m.vote_average, genres: (m.genre_ids||[]).map(id => genreMap[id]).filter(Boolean), fsk: fsk??null, rank: rank??null, }; } // ── xREL ───────────────────────────────────────────────────────────────────── const XREL_DATE_LABEL = { 'de-cine': 'Kinostart (DE)', 'us-cine': 'Kinostart (US)', 'de-web': 'Web (DE)', 'us-web': 'Web (US)', 'de-retail': 'Retail (DE)', 'us-retail': 'Retail (US)', 'de-hd': 'Blu-ray (DE)', 'us-hd': 'Blu-ray (US)', 'de-rental': 'Rental (DE)', 'us-rental': 'Rental (US)', 'r5': 'R5', }; const QUALITY_BLOCKLIST = r => /\b(2160p|4k|uhd|uhdbluray|uhdbd|remux)\b/i.test(r) || /COMPLETE[._](?:BLURAY|BD)/i.test(r); async function xrelFetch(path, params = {}) { const url = new URL(XREL_BASE + path); for (const [k,v] of Object.entries(params)) url.searchParams.set(k, String(v)); const res = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000), }); if (res.status === 429) { await new Promise(r => setTimeout(r, (parseInt(res.headers.get('Retry-After')||'10'))*1000)); const r2 = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000) }); if (!r2.ok) throw new Error(`xREL ${r2.status}`); return r2.json(); } if (!res.ok) throw new Error(`xREL ${res.status}`); return res.json(); } // Einzige Funktion für xREL: benutzt immer die ext_info_id und gibt max 5 deutsche ≤1080p Releases // Cache-Key: tmdb_id (immer eindeutig, konsistent zwischen Badge und Modal) async function getXrelData(tmdbId, imdbId, title, originalTitle, movieYear, mediaType='movie') { const cacheKey = `xrel:${mediaType}:${tmdbId}`; const cached = cacheGet(cacheKey); if (cached !== undefined) return cached; const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); // Suchqueries: original, deutsch, und normalisierte Varianten // Wichtig: xREL nutzt oft den englischen Originaltitel auch bei deutschen Filmen const rawQueries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); // Normalisierte Versionen (Umlaute) als zusätzliche Queries const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim()) .filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q)); const allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); // Alle parallel suchen const searchResults = await Promise.all(allQueries.map(async q => { try { return { q, data: await xrelFetch('/search/ext_info.json', { q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:mediaType, limit:10 }) }; } catch(_) { return null; } })); let extInfoId = null; // Strategie 1: IMDb-URI exakter Match (zuverlässigste Methode) if (imdbId) { for (const r of searchResults) { if (!r) continue; const hit = (r.data?.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`)); if (hit?.id) { extInfoId = hit.id; break; } } } // Strategie 2: Titel/alt_title Match gegen alle Queries (normalisiert) if (!extInfoId) { const allCleanQueries = allQueries.map(q => clean(q)); for (const r of searchResults) { if (!r?.data?.results) continue; for (const h of r.data.results) { if (allCleanQueries.includes(clean(h.title)) || allCleanQueries.includes(clean(h.alt_title))) { extInfoId = h.id; break; } } if (extInfoId) break; } } if (!extInfoId) { cacheSet(cacheKey, [], 60*60*1000); return []; } // Releases + ext_info Details parallel holen const [sceneData, p2pData, infoData] = await Promise.all([ xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 }).catch(()=>({})), xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 50 }).catch(()=>({})), xrelFetch('/ext_info/info.json', { id: extInfoId, type: mediaType }).catch(()=>({})), ]); // xREL Erscheinungsdaten const xrel_dates = (infoData.release_dates||[]) .filter(d => XREL_DATE_LABEL[d.type]) .map(d => ({ label: XREL_DATE_LABEL[d.type], date: d.date })) .sort((a,b) => a.date.localeCompare(b.date)); const results = []; // Jahresfilter: nur für Filme ab 2015 anwenden // Alte Filme (< 2015) haben oft Releases die Jahre später erschienen → nicht filtern const releaseYearOk = (releaseDate) => { if (!movieYear || !releaseDate) return true; if (movieYear < 2015) return true; if (mediaType === 'tv') return true; // TV-Serien: kein Jahresfilter (Staffeln erscheinen über Jahre) const y = parseInt(releaseDate.slice(0,4)); return Math.abs(y - movieYear) <= 3; }; for (const r of sceneData.list||[]) { if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue; if (QUALITY_BLOCKLIST(r.dirname||'')) continue; const date = r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null; if (!releaseYearOk(date)) continue; results.push({ dirname: r.dirname, size: r.size ? `${r.size.number} ${r.size.unit}` : null, category: 'Scene', url: r.link_href, date, p2p: false, }); } for (const r of p2pData.list||[]) { if (r.main_lang !== 'german') continue; if (QUALITY_BLOCKLIST(r.dirname||'')) continue; const date = r.pub_time ? new Date(r.pub_time*1000).toISOString().slice(0,10) : null; if (!releaseYearOk(date)) continue; results.push({ dirname: r.dirname, size: r.size_mb ? `${(r.size_mb/1024).toFixed(1)} GB` : null, category: r.category?.sub_cat||'P2P', url: r.link_href, date, p2p: true, }); } results.sort((a,b) => (b.date||'').localeCompare(a.date||'')); const top5 = results.slice(0, 5); const payload = { releases: top5, xrel_dates }; cacheSet(cacheKey, payload, top5.length > 0 || xrel_dates.length > 0 ? 12*60*60*1000 : 60*60*1000); return payload; } // ── Routes ──────────────────────────────────────────────────────────────────── router.get('/now-playing', authenticate, async (req, res) => { try { const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing',3), getGenreMap()]); const filtered = filterMovies(pages); const top10raw = filtered.slice(0,10), restRaw = filtered.slice(10); const top10 = await Promise.all(top10raw.map(async (m,i) => { let fsk = null; try { fsk = extractFSK(await tmdb(`/movie/${m.id}/release_dates`)); } catch(_) {} return enrichMovie(m, genreMap, fsk, i+1); })); res.json([...top10, ...restRaw.map(m => enrichMovie(m,genreMap,null,null))]); } catch(e) { res.status(500).json({ error: e.message }); } }); router.get('/upcoming', authenticate, async (req, res) => { try { const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming',3), getGenreMap()]); const filtered = filterMovies(pages).filter(m => m.release_date); filtered.sort((a,b) => a.release_date.localeCompare(b.release_date)); const groups = {}; for (const m of filtered) { const d = new Date(m.release_date), dow = d.getDay(); const mon = new Date(d); mon.setDate(d.getDate()+(dow===0?-6:1-dow)); const key = mon.toISOString().slice(0,10); if (!groups[key]) groups[key] = []; groups[key].push(enrichMovie(m,genreMap,null,null)); } res.json(groups); } catch(e) { res.status(500).json({ error: e.message }); } }); // GET /api/tools/media/search?q=TITEL – Filmsuche mit Wildcard-Support // hulk*2008 → sucht "hulk" mit year=2008 router.get('/search', authenticate, async (req, res) => { try { let q = (req.query.q || '').trim(); if (!q || q.length < 2) return res.json([]); // Wildcard: z.B. "hulk*2008" → query="hulk", year="2008" let yearFilter = null; const wildcardMatch = q.match(/^(.+?)\*(\d{4})$/); if (wildcardMatch) { q = wildcardMatch[1].trim(); yearFilter = wildcardMatch[2]; } const searchParams = { query: q, include_adult: 'false' }; if (yearFilter) searchParams.year = yearFilter; const [movieResults, tvResults, genreMap, tvGenreMap] = await Promise.all([ tmdb('/search/movie', searchParams), tmdb('/search/tv', { query: q, include_adult: 'false', ...(yearFilter ? { first_air_date_year: yearFilter } : {}) }), getGenreMap(), tmdb('/genre/tv/list').catch(()=>({genres:[]})), ]); const tvGenres = {}; (tvGenreMap.genres||[]).forEach(g => { tvGenres[g.id] = g.name; }); const seen = new Set(); const movies = (movieResults.results || []) .filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; if (seen.has('m'+m.id)) return false; seen.add('m'+m.id); return true; }) .map(m => ({ ...enrichMovie(m, genreMap, null, null), media_type: 'movie' })); const tvShows = (tvResults.results || []) .filter(m => { if (seen.has('t'+m.id)) return false; seen.add('t'+m.id); return true; }) .map(m => ({ id: m.id, media_type: 'tv', title: m.name || m.original_name, original_title: m.original_name, release_date: m.first_air_date || '', poster_path: m.poster_path || '', genres: (m.genre_ids||[]).map(id => tvGenres[id] || genreMap[id]).filter(Boolean), vote_average: m.vote_average, original_language: m.original_language, origin_country: m.origin_country || [], })); // Sortierung: mit Datum zuerst (neueste), ohne Datum danach const combined = [...movies, ...tvShows] .sort((a,b) => { if (!a.release_date && !b.release_date) return 0; if (!a.release_date) return 1; if (!b.release_date) return -1; return b.release_date.localeCompare(a.release_date); }) .slice(0, 30); res.json(combined); } catch(e) { res.status(500).json({ error: e.message }); } }); // TV-Serie Detail router.get('/tv/:id', authenticate, async (req, res) => { try { const [detail, genreMap, extIds] = await Promise.all([ tmdb(`/tv/${req.params.id}`, { append_to_response: 'credits,content_ratings' }), getGenreMap(), tmdb(`/tv/${req.params.id}/external_ids`).catch(()=>({})), ]); // FSK aus content_ratings (DE) let fsk = ''; const deRating = (detail.content_ratings?.results || []).find(r => r.iso_3166_1 === 'DE'); if (deRating?.rating) fsk = deRating.rating; res.json({ id: detail.id, media_type: 'tv', title: detail.name || detail.original_name, original_title: detail.original_name, tagline: detail.tagline || '', overview: detail.overview, poster_path: detail.poster_path, backdrop_path: detail.backdrop_path, release_date: detail.first_air_date || '', runtime: detail.episode_run_time?.[0] || null, vote_average: detail.vote_average, genres: (detail.genres||[]).map(g=>g.name), fsk, cast: (detail.credits?.cast||[]).slice(0,8).map(c => c.name), countries: (detail.production_countries||[]).map(c => c.name), imdb_id: extIds.imdb_id || '', number_of_seasons: detail.number_of_seasons, number_of_episodes: detail.number_of_episodes, status: detail.status, }); } catch(e) { res.status(500).json({ error: e.message }); } }); // Modal: TMDb-Daten sofort, xREL via separaten Endpoint router.get('/movie/:id', authenticate, async (req, res) => { try { const [detail, genreMap, extIds] = await Promise.all([ tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }), getGenreMap(), tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})), ]); const fsk = extractFSK(detail.release_dates); const imdbId = extIds.imdb_id || null; res.json({ id: detail.id, title: detail.title||detail.original_title, original_title: detail.original_title, tagline: detail.tagline, overview: detail.overview, poster_path: detail.poster_path, backdrop_path: detail.backdrop_path, release_date: detail.release_date, runtime: detail.runtime, vote_average: detail.vote_average, genres: (detail.genres||[]).map(g => g.name), fsk, cast: (detail.credits?.cast||[]).slice(0,8).map(a => a.name), director: detail.credits?.crew?.find(c => c.job==='Director')?.name||null, tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`, imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null, imdb_id: imdbId, countries: (detail.production_countries||[]).map(c => c.name), }); } catch(e) { res.status(500).json({ error: e.message }); } }); // xREL on-demand – ?refresh=1 ignoriert Cache, ?imdb_id=tt...&title=...&orig=... vermeidet TMDb-Calls router.get('/movie/:id/xrel', authenticate, async (req, res) => { try { const xrelMediaType = req.query.media_type || 'movie'; const cacheKey = `xrel:${xrelMediaType}:${req.params.id}`; const forceRefresh = req.query.refresh === '1'; if (!forceRefresh) { const cached = cacheGet(cacheKey); if (cached !== undefined) { const releases = Array.isArray(cached) ? cached : (cached.releases || []); const xrel_dates = Array.isArray(cached) ? [] : (cached.xrel_dates || []); if (releases.length > 0 || xrel_dates.length > 0) { return res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 }); } } } if (forceRefresh) { db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey); // Auch alten Key löschen db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(`xrel:tmdb:${req.params.id}`); } let imdbId = req.query.imdb_id || null; let title = req.query.title || null; let origTitle = req.query.orig || null; if (!title) { const endpoint = xrelMediaType === 'tv' ? 'tv' : 'movie'; const [detail, extIds] = await Promise.all([ tmdb(`/${endpoint}/${req.params.id}`, { append_to_response: '' }).catch(()=>({})), tmdb(`/${endpoint}/${req.params.id}/external_ids`).catch(()=>({})), ]); imdbId = extIds.imdb_id || null; title = detail.name || detail.title || null; origTitle = detail.original_name || detail.original_title || null; } let filmYear = null; if (req.query.year && req.query.year !== '0' && xrelMediaType !== 'tv') filmYear = parseInt(req.query.year); const data = await getXrelData(req.params.id, imdbId, title, origTitle, filmYear, xrelMediaType); const releases = Array.isArray(data) ? data : (data.releases || []); const xrel_dates = Array.isArray(data) ? [] : (data.xrel_dates || []); res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 }); } catch(e) { res.status(500).json({ error: e.message }); } }); // GET /api/tools/media/xrel-cached?ids=1,2,3 – welche Filme haben Releases im Cache router.get('/xrel-cached', authenticate, (req, res) => { const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); if (!ids.length) return res.json({}); const result = {}; for (const id of ids) { const cached = cacheGet(`xrel:movie:${id}`) || cacheGet(`xrel:tv:${id}`) || cacheGet(`xrel:tmdb:${id}`); result[id] = cached !== undefined && (Array.isArray(cached) ? cached.length > 0 : cached.releases?.length > 0); } res.json(result); }); // ── Streaming Top 10 pro Anbieter ────────────────────────────────────────────── // TMDb Watch-Provider-IDs für Deutschland (watch_region=DE) // HINWEIS: Eine frühere Version hat hier versucht die offizielle Netflix-XLSX-Datei // (komplette Historie seit 2021, alle ~90 Länder) direkt zu laden und zu parsen. // Das hat den Container überlastet (CPU ~90%, RAM voll, Container kurzzeitig nicht // erreichbar) – die Datei ist für ein synchrones Parsing im selben Prozess zu groß. // Netflix läuft daher wie alle anderen Anbieter über die leichtgewichtige // TMDb-Näherung weiter unten. const STREAMING_PROVIDERS = [ { id: 'netflix', label: 'Netflix', tmdbId: '8' }, { id: 'disney', label: 'Disney+', tmdbId: '337' }, { id: 'prime', label: 'Amazon Prime Video', tmdbId: '9|119' }, { id: 'appletv', label: 'Apple TV+', tmdbId: '350' }, { id: 'paramount', label: 'Paramount+', tmdbId: '531' }, { id: 'wow', label: 'WOW (Sky)', tmdbId: '30' }, { id: 'rtlplus', label: 'RTL+', tmdbId: '421' }, ]; // Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen // MUSS vor /streaming/:providerId stehen, sonst matched Express "all-providers" als providerId! router.get('/streaming/all-providers', authenticate, requireAdmin, async (req, res) => { try { const [movieProv, tvProv] = await Promise.all([ tmdb('/watch/providers/movie', { watch_region: 'DE' }), tmdb('/watch/providers/tv', { watch_region: 'DE' }), ]); const merged = {}; for (const p of [...(movieProv.results||[]), ...(tvProv.results||[])]) { merged[p.provider_id] = { id: p.provider_id, name: p.provider_name, logo: p.logo_path, priority: p.display_priorities?.DE ?? p.display_priority ?? 999 }; } const list = Object.values(merged).sort((a,b) => a.priority - b.priority); res.json(list); } catch(e) { res.status(500).json({ error: e.message }); } }); router.get('/streaming/providers', authenticate, (req, res) => { res.json(STREAMING_PROVIDERS.map(p => ({ id: p.id, label: p.label }))); }); // GET /streaming/:providerId – Top 10 Filme + Serien für einen Anbieter (DE) router.get('/streaming/:providerId', authenticate, async (req, res) => { try { const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); // Ergebnis 6h cachen – die Trending+Provider-Checks machen mehrere // zusätzliche TMDb-Calls, das muss nicht bei jedem Seitenaufruf neu laufen const streamCacheKey = `streaming-top10:${provider.id}`; const cachedResult = cacheGet(streamCacheKey); if (cachedResult !== undefined) return res.json(cachedResult); // Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1) const minYear = new Date().getFullYear() - 1; const minDate = `${minYear}-01-01`; // ── Strategie 1: TMDb "trending diese Woche" + Provider-Check pro Titel ── // /trending unterstützt KEINEN watch_provider-Filter, deshalb: erst die // wöchentlich angesagten Titel holen (kleine Liste, ~20 Treffer), dann für // jeden einzeln per /watch/providers prüfen ob er beim gewählten Anbieter // in DE verfügbar ist. So tauchen frische virale Hits (z.B. ein Titel der // gerade erst diese Woche durchstartet) sofort auf, statt erst Wochen // später über die reine Gesamt-Popularität reinzurutschen. async function getTrendingForProvider(mediaKind) { try { const path = mediaKind === 'movie' ? '/trending/movie/week' : '/trending/tv/week'; const trending = await tmdb(path, {}); const candidates = (trending.results || []).slice(0, 20); const checked = await Promise.all(candidates.map(async item => { try { const provPath = mediaKind === 'movie' ? `/movie/${item.id}/watch/providers` : `/tv/${item.id}/watch/providers`; const provData = await tmdb(provPath, {}); const deFlat = provData.results?.DE?.flatrate || []; const matchIds = provider.tmdbId.split('|').map(Number); const available = deFlat.some(p => matchIds.includes(p.provider_id)); return available ? item : null; } catch { return null; } })); return checked.filter(Boolean); } catch { return []; } // Trending-Strategie ist nur eine Ergänzung – bei Fehler einfach leer } // Höhere vote_count-Schwelle filtert Nischentitel jeder Herkunft heraus. // Bei kleineren Katalogen (z.B. RTL+) reicht das oft nicht für 10 Treffer – // dann wird mit niedrigerer Schwelle nachgeladen (siehe fetchWithFallback unten). const MIN_VOTES = 300; const FALLBACK_MIN_VOTES = 50; // Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt // erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen // gilt ein Deckel von 2 Plätzen in den Top 10 – AUSSER ein Titel hat eine // sehr hohe vote_count (= ist ein echter, in DE bekannter Crossover-Hit wie // Squid Game), dann zählt er nicht gegen den Deckel. const UNLIMITED_LANGS = new Set(['de', 'en']); const MAX_PER_OTHER_LANGUAGE = 2; const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug" function buildParams(mediaKind, minVotes) { return { watch_region: 'DE', with_watch_providers: provider.tmdbId, with_watch_monetization_types: 'flatrate', sort_by: 'popularity.desc', 'vote_count.gte': minVotes, ...(mediaKind === 'movie' ? { 'primary_release_date.gte': minDate } : { 'first_air_date.gte': minDate }), }; } async function fetchThreePages(mediaKind, minVotes) { const params = buildParams(mediaKind, minVotes); const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; const [p1, p2, p3] = await Promise.all([ tmdb(path, { ...params, page: 1 }), tmdb(path, { ...params, page: 2 }), tmdb(path, { ...params, page: 3 }), ]); return [...(p1.results||[]), ...(p2.results||[]), ...(p3.results||[])]; } // Erst Trending+Provider-Check (frische Hits), dann Discover mit hoher // Schwelle, bei zu wenig Ergebnissen mit niedrigerer Schwelle nachladen async function fetchWithFallback(mediaKind) { const trendingHits = await getTrendingForProvider(mediaKind); let raw = await fetchThreePages(mediaKind, MIN_VOTES); let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language)); // Trending-Treffer vorne einfügen (Duplikate vermeiden) const seenIds = new Set(filtered.map(m => m.id)); const merged = []; for (const t of trendingHits) { if (!seenIds.has(t.id) && !BLOCKED_LANGS.has(t.original_language)) { merged.push(t); seenIds.add(t.id); } } merged.push(...filtered); filtered = merged; if (filtered.length < 10) { const fallback = await fetchThreePages(mediaKind, FALLBACK_MIN_VOTES); for (const m of fallback) { if (!BLOCKED_LANGS.has(m.original_language) && !seenIds.has(m.id)) { filtered.push(m); seenIds.add(m.id); } } } return filtered; } const [movieRaw, tvRaw, genreMap] = await Promise.all([ fetchWithFallback('movie'), fetchWithFallback('tv'), getGenreMap(), ]); // Liste bleibt nach Popularität sortiert. Deutsch/Englisch immer erlaubt. // Andere Sprachen: max. 2 Plätze, außer der Titel ist ein nachweislicher // Crossover-Hit (sehr hohe vote_count) – der zählt dann nicht gegen den Deckel. function applyLanguageWeighting(rawList) { const otherLangCount = {}; const picked = []; const overflow = []; for (const item of rawList) { const lang = item.original_language || 'xx'; const isUnlimited = UNLIMITED_LANGS.has(lang); const isCrossoverHit = (item.vote_count || 0) >= CROSSOVER_HIT_VOTE_THRESHOLD; if (isUnlimited || isCrossoverHit) { picked.push(item); } else if ((otherLangCount[lang] || 0) < MAX_PER_OTHER_LANGUAGE) { picked.push(item); otherLangCount[lang] = (otherLangCount[lang] || 0) + 1; } else { overflow.push(item); } if (picked.length >= 10) break; } for (const item of overflow) { if (picked.length >= 10) break; picked.push(item); } return picked.slice(0, 10); } const movies = applyLanguageWeighting(movieRaw) .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); const tvShows = applyLanguageWeighting(tvRaw) .map((m, i) => ({ id: m.id, media_type: 'tv', rank: i + 1, title: m.name || m.original_name, original_title: m.original_name, release_date: m.first_air_date || '', poster_path: m.poster_path || '', genres: (m.genre_ids||[]).map(gid => genreMap[gid]).filter(Boolean), vote_average: m.vote_average, original_language: m.original_language, origin_country: m.origin_country || [], })); const result = { provider: { id: provider.id, label: provider.label }, movies, tvShows, filterInfo: { minYear, minVotes: MIN_VOTES, maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE, }, }; cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache res.json(result); } catch(e) { res.status(500).json({ error: e.message }); } }); // ── Favorites Routen (feste VOR :param) ────────────────────────────────────── // GET /favorites – eigene Favoriten router.get('/favorites', authenticate, (req, res) => { res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id)); }); // GET /favorites/all – Admin: alle User gruppiert router.get('/favorites/all', authenticate, requireAdmin, (req, res) => { const favs = db.prepare(` SELECT f.*, u.username FROM movie_favorites f JOIN users u ON u.id = f.user_id ORDER BY u.username ASC, f.added_at DESC `).all(); res.json(favs); }); // POST /favorites/user-notify – User hat quittierte Favoriten gesehen router.post('/favorites/user-notify', authenticate, (req, res) => { db.prepare('UPDATE movie_favorites SET user_notified=1 WHERE user_id=? AND acknowledged=1 AND user_notified=0').run(req.user.id); res.json({ ok: true }); }); // GET /favorites/unacked – unquittierte Zählung (Admin: neue Favoriten; User: quittierte aber unbestätigte) router.get('/favorites/unacked', authenticate, (req, res) => { const isAdmin = req.user.role === 'admin'; if (isAdmin) { const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get(); res.json({ count: count.n, isAdmin: true }); } else { // User: Favoriten die vom Admin quittiert wurden aber User noch nicht gesehen hat const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE user_id=? AND acknowledged=1 AND user_notified=0').get(req.user.id); res.json({ count: count.n, isAdmin: false }); } }); // POST /favorites/acknowledge-all – Admin: alle quittieren router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run(); res.json({ ok: true }); }); // POST /favorites – Favorit hinzufügen (alle User inkl. Admin bekommen Pushover) router.post('/favorites', authenticate, (req, res) => { const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk, media_type } = req.body; if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' }); try { const r = db.prepare(`INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk,media_type,added_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||'', media_type||'movie'); if (r.changes > 0) { try { // Admin-Pushover-Einstellungen aus pushover_settings Tabelle const adminUser = db.prepare("SELECT id FROM users WHERE role='admin' LIMIT 1").get(); const poCfg = adminUser ? db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(adminUser.id) : null; const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id); if (poCfg?.app_token && poCfg?.user_key) { fetch('https://api.pushover.net/1/messages.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`, title: '⭐ Neue Favoriten', priority: 0, }), }).catch(() => {}); } } catch {} } res.json({ ok: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); // DELETE /favorites/:tmdb_id – eigenen Favoriten löschen (jeder User) router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id)); res.json({ ok: true }); }); // POST /favorites/:id/acknowledge – Admin: einzelnen quittieren router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => { db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(req.params.id); res.json({ ok: true }); }); // Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen router.get('/settings', authenticate, requireAdmin, (req, res) => res.json({ configured: !!getToken() })); router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim()); genreCache = null; res.json({ ok: true }); }); // DEBUG ext_info – nach Test entfernen router.get('/debug-extinfo', authenticate, async (req, res) => { const id = req.query.id || ''; if (!id) return res.json({ error: 'id required' }); try { const data = await xrelFetch('/ext_info/info.json', { id, type: 'movie' }); res.json(data); } catch(e) { res.status(500).json({ error: e.message }); } }); // DEBUG – nach Test entfernen router.get('/debug-xrel3', authenticate, async (req, res) => { const title = req.query.title || ''; const orig = req.query.orig || ''; const imdb_id = req.query.imdb_id || null; const log = { title, orig, imdb_id }; try { const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); const rawQueries = [orig, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim()) .filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q)); log.rawQueries = rawQueries; log.normQueries = normQueries; log.allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); const results = []; for (const q of log.allQueries) { try { const data = await xrelFetch('/search/ext_info.json', { q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:'movie', limit:5 }); results.push({ query: normalizeUmlauts(q).replace(/[*#!?:]/g,''), hits: (data.results||[]).map(h => ({ id: h.id, title: h.title, alt_title: h.alt_title, uris: h.uris })) }); } catch(e) { results.push({ query: q, error: e.message }); } } log.searchResults = results; } catch(e) { log.error = e.message; } res.json(log); }); // Debug xREL für TV router.get('/debug-xrel-tv', authenticate, async (req, res) => { const { title='', orig='', imdb_id='', year='0', id='0' } = req.query; try { const clean = s => (s||'').toLowerCase().trim(); const allQueries = [orig, title].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i); // 1. Suche const searchResults = await Promise.all(allQueries.map(async q => { try { const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#!?:]/g,''), type:'tv', limit:10 }); return { q, results: data?.results||[], total: data?.total||0 }; } catch(e) { return { q, error: e.message }; } })); // 2. IMDb-Match let extInfoId = null; if (imdb_id) { for (const r of searchResults) { const hit = (r.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdb_id}`)); if (hit) { extInfoId = hit.id; break; } } } // 3. Titel-Match if (!extInfoId) { for (const r of searchResults) { for (const h of r.results||[]) { if (clean(h.title)===clean(orig)||clean(h.title)===clean(title)|| clean(h.alt_title)===clean(orig)||clean(h.alt_title)===clean(title)) { extInfoId = h.id; break; } } if (extInfoId) break; } } // 4. Releases wenn gefunden let releases = []; if (extInfoId) { const [scene, p2p] = await Promise.all([ xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 25 }).catch(()=>({})), xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 25 }).catch(()=>({})), ]); releases = [ ...(scene.list||[]).map(r=>({ src:'scene', dirname:r.dirname, german:/german|deutsch/i.test(r.dirname||'') })), ...(p2p.list||[]).map(r=>({ src:'p2p', dirname:r.dirname, german: r.main_lang==='german' })), ]; } res.json({ searchResults, extInfoId, releases: releases.slice(0,20) }); } catch(e) { res.status(500).json({ error: e.message }); } }); module.exports = router;