feat: media modal – FSK-Fix, TMDb+IMDb Links, xREL Releases
This commit is contained in:
@@ -4,6 +4,7 @@ const db = require('../../db');
|
|||||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||||
|
|
||||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||||
|
const XREL_BASE = 'https://api.xrel.to/v2';
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || '';
|
return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || '';
|
||||||
@@ -33,13 +34,18 @@ async function getGenreMap() {
|
|||||||
return genreCache;
|
return genreCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FSK-Fix: alle release_dates durchsuchen, ersten nicht-leeren Cert nehmen
|
||||||
|
// Bevorzugt Typ 3 (Kino), aber nimmt jeden nicht-leeren Wert
|
||||||
function extractFSK(releaseDates) {
|
function extractFSK(releaseDates) {
|
||||||
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
|
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
|
||||||
if (!de) return null;
|
if (!de) return null;
|
||||||
return de.release_dates.find(r => r.certification)?.certification || null;
|
// Erst Kino (type=3), dann alle anderen
|
||||||
|
const sorted = [...de.release_dates].sort((a, b) =>
|
||||||
|
(a.type === 3 ? 0 : 1) - (b.type === 3 ? 0 : 1)
|
||||||
|
);
|
||||||
|
return sorted.find(r => r.certification && r.certification.trim() !== '')?.certification || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Seiten holen (parallel)
|
|
||||||
async function fetchAllPages(path, maxPages = 3) {
|
async function fetchAllPages(path, maxPages = 3) {
|
||||||
const first = await tmdb(path, { page: 1 });
|
const first = await tmdb(path, { page: 1 });
|
||||||
const total = Math.min(first.total_pages || 1, maxPages);
|
const total = Math.min(first.total_pages || 1, maxPages);
|
||||||
@@ -69,28 +75,23 @@ function enrichMovie(movie, genreMap, fsk, rank) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/tools/media/now-playing
|
// GET /api/tools/media/now-playing
|
||||||
// FSK nur für Top 10 parallel holen, Rest ohne – kein sequentielles Warten
|
|
||||||
router.get('/now-playing', authenticate, async (req, res) => {
|
router.get('/now-playing', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [pages, genreMap] = await Promise.all([
|
const [pages, genreMap] = await Promise.all([
|
||||||
fetchAllPages('/movie/now_playing', 3),
|
fetchAllPages('/movie/now_playing', 3),
|
||||||
getGenreMap(),
|
getGenreMap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filtered = pages.filter(m => m.original_language !== 'tr');
|
const filtered = pages.filter(m => m.original_language !== 'tr');
|
||||||
|
|
||||||
// Top 10: FSK parallel holen
|
|
||||||
const top10raw = filtered.slice(0, 10);
|
const top10raw = filtered.slice(0, 10);
|
||||||
const restRaw = filtered.slice(10);
|
const restRaw = filtered.slice(10);
|
||||||
|
|
||||||
|
// Top 10: FSK parallel holen
|
||||||
const top10 = await Promise.all(top10raw.map(async (m, i) => {
|
const top10 = await Promise.all(top10raw.map(async (m, i) => {
|
||||||
let fsk = null;
|
let fsk = null;
|
||||||
try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {}
|
try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {}
|
||||||
return enrichMovie(m, genreMap, fsk, i + 1);
|
return enrichMovie(m, genreMap, fsk, i + 1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rest = restRaw.map(m => enrichMovie(m, genreMap, null, null));
|
const rest = restRaw.map(m => enrichMovie(m, genreMap, null, null));
|
||||||
|
|
||||||
res.json([...top10, ...rest]);
|
res.json([...top10, ...rest]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
@@ -98,51 +99,81 @@ router.get('/now-playing', authenticate, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/upcoming
|
// GET /api/tools/media/upcoming
|
||||||
// Wochenweise gruppiert, kein FSK-Lookup beim Listing
|
|
||||||
router.get('/upcoming', authenticate, async (req, res) => {
|
router.get('/upcoming', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [pages, genreMap] = await Promise.all([
|
const [pages, genreMap] = await Promise.all([
|
||||||
fetchAllPages('/movie/upcoming', 3),
|
fetchAllPages('/movie/upcoming', 3),
|
||||||
getGenreMap(),
|
getGenreMap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const filtered = pages
|
const filtered = pages
|
||||||
.filter(m => m.original_language !== 'tr')
|
.filter(m => m.original_language !== 'tr')
|
||||||
.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; })
|
.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; })
|
||||||
.filter(m => m.release_date);
|
.filter(m => m.release_date);
|
||||||
|
|
||||||
filtered.sort((a, b) => a.release_date.localeCompare(b.release_date));
|
filtered.sort((a, b) => a.release_date.localeCompare(b.release_date));
|
||||||
|
|
||||||
// Wochenweise gruppieren (Montag als Key)
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
for (const m of filtered) {
|
for (const m of filtered) {
|
||||||
const d = new Date(m.release_date);
|
const d = new Date(m.release_date);
|
||||||
const dow = d.getDay(); // 0=So
|
const dow = d.getDay();
|
||||||
const diff = dow === 0 ? -6 : 1 - dow;
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
const mon = new Date(d);
|
const mon = new Date(d); mon.setDate(d.getDate() + diff);
|
||||||
mon.setDate(d.getDate() + diff);
|
|
||||||
const key = mon.toISOString().slice(0, 10);
|
const key = mon.toISOString().slice(0, 10);
|
||||||
if (!groups[key]) groups[key] = [];
|
if (!groups[key]) groups[key] = [];
|
||||||
groups[key].push(enrichMovie(m, genreMap, null, null));
|
groups[key].push(enrichMovie(m, genreMap, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(groups);
|
res.json(groups);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tools/media/movie/:id – Details + Credits (nur für Modal)
|
// GET /api/tools/media/movie/:id – Details + Credits + xREL + Links
|
||||||
router.get('/movie/:id', authenticate, async (req, res) => {
|
router.get('/movie/:id', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [detail, genreMap] = await Promise.all([
|
const [detail, genreMap, extIds] = await Promise.all([
|
||||||
tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }),
|
tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }),
|
||||||
getGenreMap(),
|
getGenreMap(),
|
||||||
|
tmdb(`/movie/${req.params.id}/external_ids`).catch(() => ({})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const fsk = extractFSK(detail.release_dates);
|
||||||
|
const imdb_id = extIds.imdb_id || detail.imdb_id || null;
|
||||||
|
const tmdb_url = `https://www.themoviedb.org/movie/${detail.id}`;
|
||||||
|
const imdb_url = imdb_id ? `https://www.imdb.com/title/${imdb_id}` : null;
|
||||||
|
|
||||||
|
// xREL-Suche: original_title + Jahr
|
||||||
|
let xrel = [];
|
||||||
|
try {
|
||||||
|
const year = detail.release_date?.slice(0, 4) || '';
|
||||||
|
const query = encodeURIComponent(detail.original_title || detail.title);
|
||||||
|
const xUrl = `${XREL_BASE}/search/releases.json?q=${query}&scene=true&p2p=true&limit=25`;
|
||||||
|
const xRes = await fetch(xUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
|
||||||
|
if (xRes.ok) {
|
||||||
|
const xData = await xRes.json();
|
||||||
|
const hits = (xData.results?.scene?.releases || []).concat(xData.results?.p2p?.releases || []);
|
||||||
|
// Nur Movie-Releases, nach Erscheinungsjahr filtern wenn bekannt
|
||||||
|
xrel = hits
|
||||||
|
.filter(r => {
|
||||||
|
const cat = r.category?.name || '';
|
||||||
|
return /movie|film|xvid|x264|x265|bluray|uhd|dvdr|hdtv/i.test(cat);
|
||||||
|
})
|
||||||
|
.map(r => ({
|
||||||
|
dirname: r.dirname,
|
||||||
|
size: r.size_mb ? `${(r.size_mb/1024).toFixed(1)} GB` : null,
|
||||||
|
category: r.category?.name || '',
|
||||||
|
url: r.link_href || `https://www.xrel.to/release/${r.id}.html`,
|
||||||
|
date: r.time ? new Date(r.time * 1000).toISOString().slice(0,10) : null,
|
||||||
|
p2p: !!r.p2p,
|
||||||
|
}))
|
||||||
|
.slice(0, 15);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
title: detail.title || detail.original_title,
|
title: detail.title || detail.original_title,
|
||||||
|
original_title: detail.original_title,
|
||||||
tagline: detail.tagline,
|
tagline: detail.tagline,
|
||||||
overview: detail.overview,
|
overview: detail.overview,
|
||||||
poster_path: detail.poster_path,
|
poster_path: detail.poster_path,
|
||||||
@@ -150,10 +181,14 @@ router.get('/movie/:id', authenticate, async (req, res) => {
|
|||||||
release_date: detail.release_date,
|
release_date: detail.release_date,
|
||||||
runtime: detail.runtime,
|
runtime: detail.runtime,
|
||||||
vote_average: detail.vote_average,
|
vote_average: detail.vote_average,
|
||||||
|
vote_count: detail.vote_count,
|
||||||
genres: (detail.genres || []).map(g => g.name),
|
genres: (detail.genres || []).map(g => g.name),
|
||||||
fsk: extractFSK(detail.release_dates),
|
fsk,
|
||||||
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
|
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
|
||||||
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
|
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
|
||||||
|
tmdb_url,
|
||||||
|
imdb_url,
|
||||||
|
xrel,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
|
|||||||
@@ -531,6 +531,63 @@ function MovieModal({ tmdbId, onClose, mobile }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div style={{ marginTop:18, display:'flex', gap:8, flexWrap:'wrap' }}>
|
||||||
|
{detail.tmdb_url && (
|
||||||
|
<a href={detail.tmdb_url} target="_blank" rel="noopener noreferrer" style={{
|
||||||
|
display:'inline-flex', alignItems:'center', gap:5,
|
||||||
|
fontSize:11, color:'#01d277', background:'rgba(1,210,119,0.1)',
|
||||||
|
border:'1px solid rgba(1,210,119,0.25)', borderRadius:6,
|
||||||
|
padding:'5px 11px', fontFamily:'monospace', textDecoration:'none',
|
||||||
|
}}>🎬 TMDb</a>
|
||||||
|
)}
|
||||||
|
{detail.imdb_url && (
|
||||||
|
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" style={{
|
||||||
|
display:'inline-flex', alignItems:'center', gap:5,
|
||||||
|
fontSize:11, color:'#f5c518', background:'rgba(245,197,24,0.1)',
|
||||||
|
border:'1px solid rgba(245,197,24,0.25)', borderRadius:6,
|
||||||
|
padding:'5px 11px', fontFamily:'monospace', textDecoration:'none',
|
||||||
|
}}>⭐ IMDb</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* xREL Releases */}
|
||||||
|
{detail.xrel?.length > 0 && (
|
||||||
|
<div style={{ marginTop:18 }}>
|
||||||
|
<div style={{ ...S.head, marginBottom:8 }}>RELEASES (XREL.TO)</div>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
|
||||||
|
{detail.xrel.map((r, i) => (
|
||||||
|
<a key={i} href={r.url} target="_blank" rel="noopener noreferrer" style={{
|
||||||
|
display:'flex', alignItems:'center', gap:8,
|
||||||
|
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)',
|
||||||
|
borderRadius:6, padding:'6px 10px', textDecoration:'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize:9, fontWeight:700, fontFamily:'monospace',
|
||||||
|
color: r.p2p ? '#4ecdc4' : '#ff6b9d',
|
||||||
|
background: r.p2p ? 'rgba(78,205,196,0.12)' : 'rgba(255,107,157,0.12)',
|
||||||
|
borderRadius:3, padding:'1px 5px', flexShrink:0 }}>
|
||||||
|
{r.p2p ? 'P2P' : 'SCN'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize:10, color:'rgba(255,255,255,0.75)', fontFamily:'monospace',
|
||||||
|
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{r.dirname}
|
||||||
|
</span>
|
||||||
|
{r.size && (
|
||||||
|
<span style={{ fontSize:9, color:'rgba(255,255,255,0.35)', fontFamily:'monospace', flexShrink:0 }}>
|
||||||
|
{r.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.date && (
|
||||||
|
<span style={{ fontSize:9, color:'rgba(255,255,255,0.25)', fontFamily:'monospace', flexShrink:0 }}>
|
||||||
|
{r.date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user