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; // ── 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; } 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 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) { const cacheKey = `xrel:tmdb:${tmdbId}`; const cached = cacheGet(cacheKey); if (cached !== undefined) return cached; // Alle Suchen parallel – originaltitel und deutsch gleichzeitig const clean = (s) => (s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); const queries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); const searchResults = await Promise.all(queries.map(async q => { try { return await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#!?:]/g,''), type:'movie', limit:10 }); } catch(_) { return null; } })); let extInfoId = null; // IMDb-URI Match zuerst if (imdbId) { for (const data of searchResults) { const hit = (data?.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`)); if (hit?.id) { extInfoId = hit.id; break; } } } // Exakter Titel-Match als Fallback if (!extInfoId) { for (const data of searchResults) { const hit = (data?.results||[]).find(h => clean(h.title)===clean(queries[0]) || clean(h.alt_title)===clean(queries[0])); if (hit?.id) { extInfoId = hit.id; break; } } } if (!extInfoId) { cacheSet(cacheKey, [], 60*60*1000); // 1h für nicht gefunden return []; } // Releases holen: Scene + P2P parallel, nur german, nur ≤1080p, max 5 const [sceneData, p2pData] = 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(()=>({})), ]); const results = []; for (const r of sceneData.list||[]) { if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue; if (QUALITY_BLOCKLIST(r.dirname||'')) continue; results.push({ dirname: r.dirname, size: r.size ? `${r.size.number} ${r.size.unit}` : null, category: 'Scene', url: r.link_href, date: r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null, p2p: false, }); } for (const r of p2pData.list||[]) { if (r.main_lang !== 'german') continue; if (QUALITY_BLOCKLIST(r.dirname||'')) 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: r.pub_time ? new Date(r.pub_time*1000).toISOString().slice(0,10) : null, p2p: true, }); } results.sort((a,b) => (b.date||'').localeCompare(a.date||'')); const top5 = results.slice(0, 5); cacheSet(cacheKey, top5, top5.length > 0 ? 12*60*60*1000 : 60*60*1000); return top5; } // ── 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 }); } }); // 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 den Cache (erneute Suche) router.get('/movie/:id/xrel', authenticate, async (req, res) => { try { const cacheKey = `xrel:tmdb:${req.params.id}`; const forceRefresh = req.query.refresh === '1'; if (!forceRefresh) { const cached = cacheGet(cacheKey); if (cached !== undefined) return res.json({ xrel: cached, has_xrel: cached.length > 0 }); } else { // Cache-Eintrag löschen für diesen Film db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey); } const [detail, extIds] = await Promise.all([ tmdb(`/movie/${req.params.id}`, { append_to_response: '' }).catch(()=>({})), tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})), ]); const imdbId = extIds.imdb_id || null; const xrel = await getXrelData(req.params.id, imdbId, detail.title, detail.original_title); res.json({ xrel, has_xrel: xrel.length > 0 }); } catch(e) { res.status(500).json({ error: e.message }); } }); router.get('/favorites', authenticate, (req, res) => { res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id)); }); router.post('/favorites', authenticate, (req, res) => { const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk } = req.body; if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' }); try { db.prepare(`INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk) VALUES (?,?,?,?,?,?,?,?)`) .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||''); res.json({ ok: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id)); res.json({ ok: true }); }); router.get('/settings', authenticate, requireAdmin, (req, res) => res.json({ configured: !!getToken() })); router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim()); genreCache = null; res.json({ ok: true }); }); module.exports = router;