feat: media – portal modal scroll fix, alle Kinofilme, KW-Neustarts, Top-Badges, GlobalSearch

This commit is contained in:
2026-06-06 20:36:09 +02:00
parent 87c02652b5
commit ae9291b8d6
3 changed files with 331 additions and 240 deletions

View File

@@ -23,7 +23,7 @@ async function tmdb(path, params = {}) {
return res.json();
}
// Genre-Cache (in-memory, wird bei erstem Aufruf befüllt)
// Genre-Cache
let genreCache = null;
async function getGenreMap() {
if (genreCache) return genreCache;
@@ -33,109 +33,139 @@ async function getGenreMap() {
return genreCache;
}
// FSK aus release_dates extrahieren
function extractFSK(releaseDates) {
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
if (!de) return null;
const cert = de.release_dates.find(r => r.certification)?.certification;
return cert || null;
return de.release_dates.find(r => r.certification)?.certification || null;
}
// Einen Film anreichern (Genres + FSK aus append_to_response)
function enrichMovie(movie, genreMap, releaseDates) {
// Alle Seiten einer TMDb-Liste holen (bis max. maxPages)
async function fetchAllPages(path, maxPages = 5) {
const first = await tmdb(path, { page: 1 });
const total = Math.min(first.total_pages || 1, maxPages);
let results = [...first.results];
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 enrichMovie(movie, genreMap, fsk, rank) {
return {
id: movie.id,
title: movie.title || movie.original_title,
original_title: movie.original_title,
id: movie.id,
title: movie.title || movie.original_title,
original_title: movie.original_title,
original_language: movie.original_language,
poster_path: movie.poster_path,
backdrop_path: movie.backdrop_path,
overview: movie.overview,
release_date: movie.release_date,
vote_average: movie.vote_average,
genres: (movie.genre_ids || movie.genres?.map(g=>g.id) || [])
.map(id => genreMap[id]).filter(Boolean),
fsk: extractFSK(releaseDates),
poster_path: movie.poster_path,
overview: movie.overview,
release_date: movie.release_date,
vote_average: movie.vote_average,
genres: (movie.genre_ids || movie.genres?.map(g => g.id) || [])
.map(id => genreMap[id]).filter(Boolean),
fsk: fsk || null,
rank: rank || null,
};
}
// GET /api/tools/media/now-playing
// FSK für eine Liste von Filmen parallel holen
async function withFSK(movies, genreMap, topN = 10) {
return Promise.all(movies.map(async (m, i) => {
let fsk = null;
try {
const detail = await tmdb(`/movie/${m.id}/release_dates`);
fsk = extractFSK(detail);
} catch (_) {}
return enrichMovie(m, genreMap, fsk, i < topN ? i + 1 : null);
}));
}
// GET /api/tools/media/now-playing alles was aktuell in DE läuft
router.get('/now-playing', authenticate, async (req, res) => {
try {
const [listData, genreMap] = await Promise.all([
tmdb('/movie/now_playing'),
const [all, genreMap] = await Promise.all([
fetchAllPages('/movie/now_playing', 3),
getGenreMap(),
]);
// Türkische Produktionen rausfiltern, max 10
const filtered = listData.results
.filter(m => m.original_language !== 'tr')
.slice(0, 10);
// FSK für jeden Film parallel holen
const withFSK = await Promise.all(filtered.map(async (m, idx) => {
try {
const detail = await tmdb(`/movie/${m.id}/release_dates`);
return { ...enrichMovie(m, genreMap, detail), rank: idx + 1 };
} catch {
return { ...enrichMovie(m, genreMap, null), rank: idx + 1 };
}
}));
res.json(withFSK);
const filtered = all.filter(m => m.original_language !== 'tr');
const enriched = await withFSK(filtered, genreMap, 10);
res.json(enriched);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/tools/media/upcoming
// GET /api/tools/media/upcoming wochenweise Neustarts, alle verfügbaren
router.get('/upcoming', authenticate, async (req, res) => {
try {
const [listData, genreMap] = await Promise.all([
tmdb('/movie/upcoming'),
const [all, genreMap] = await Promise.all([
fetchAllPages('/movie/upcoming', 4),
getGenreMap(),
]);
const filtered = listData.results
.filter(m => m.original_language !== 'tr')
.slice(0, 10);
const filtered = all.filter(m => m.original_language !== 'tr');
const withFSK = await Promise.all(filtered.map(async (m, idx) => {
try {
const detail = await tmdb(`/movie/${m.id}/release_dates`);
return { ...enrichMovie(m, genreMap, detail), rank: idx + 1 };
} catch {
return { ...enrichMovie(m, genreMap, null), rank: idx + 1 };
}
}));
res.json(withFSK);
// Deduplizieren nach ID
const seen = new Set();
const unique = filtered.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; });
// Nach Erscheinungsdatum sortieren
unique.sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''));
// Wochenweise gruppieren (Montag der jeweiligen Woche als Key)
const groups = {};
for (const m of unique) {
if (!m.release_date) continue;
const d = new Date(m.release_date);
// Montag der Woche berechnen
const day = d.getDay(); // 0=So
const diff = (day === 0 ? -6 : 1 - day);
const mon = new Date(d);
mon.setDate(d.getDate() + diff);
const key = mon.toISOString().slice(0, 10);
if (!groups[key]) groups[key] = [];
groups[key].push(m);
}
// Anreichern: nur die erste Gruppe bekommt FSK (spart API-Calls), Rest ohne
const result = {};
let globalRank = 0;
for (const [week, movies] of Object.entries(groups)) {
result[week] = await Promise.all(movies.map(async m => {
globalRank++;
let fsk = null;
try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {}
return enrichMovie(m, genreMap, fsk, null);
}));
}
res.json(result); // { "2026-06-02": [...], "2026-06-09": [...], ... }
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/tools/media/movie/:id Details + Credits für Modal
// GET /api/tools/media/movie/:id Detail + Credits für Modal
router.get('/movie/:id', authenticate, async (req, res) => {
try {
const [detail, genreMap] = await Promise.all([
tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }),
getGenreMap(),
]);
const cast = (detail.credits?.cast || []).slice(0, 8).map(a => a.name);
const director = detail.credits?.crew?.find(c => c.job === 'Director')?.name || null;
res.json({
id: detail.id,
title: detail.title || detail.original_title,
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,
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,
vote_count: detail.vote_count,
genres: (detail.genres || []).map(g => g.name),
fsk: extractFSK(detail.release_dates),
cast,
director,
release_date: detail.release_date,
runtime: detail.runtime,
vote_average: detail.vote_average,
vote_count: detail.vote_count,
genres: (detail.genres || []).map(g => g.name),
fsk: extractFSK(detail.release_dates),
cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
});
} catch (e) {
res.status(500).json({ error: e.message });
@@ -184,7 +214,7 @@ 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; // Cache invalidieren
genreCache = null;
res.json({ ok: true });
});