diff --git a/backend/src/db.js b/backend/src/db.js index 6535389..2a71f4b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -545,6 +545,17 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='mov `); } +// movie_favorites neue Spalten (Migration) +{ + const cols = db.pragma('table_info(movie_favorites)').map(c => c.name); + if (!cols.includes('release_date')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''"); + if (!cols.includes('genres')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'"); + if (!cols.includes('fsk')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''"); +} + // TMDb-Token Default if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()) db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run(); diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 029268b..7fcb382 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -23,59 +23,168 @@ async function tmdb(path, params = {}) { return res.json(); } +// Genre-Cache (in-memory, wird bei erstem Aufruf befüllt) +let genreCache = null; +async function getGenreMap() { + if (genreCache) return genreCache; + const data = await tmdb('/genre/movie/list'); + genreCache = {}; + for (const g of data.genres) genreCache[g.id] = g.name; + 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; +} + +// Einen Film anreichern (Genres + FSK aus append_to_response) +function enrichMovie(movie, genreMap, releaseDates) { + return { + 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), + }; +} + +// GET /api/tools/media/now-playing router.get('/now-playing', authenticate, async (req, res) => { try { - const data = await tmdb('/movie/now_playing'); - res.json(data.results.slice(0, 10)); + const [listData, genreMap] = await Promise.all([ + tmdb('/movie/now_playing'), + 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); } catch (e) { res.status(500).json({ error: e.message }); } }); +// GET /api/tools/media/upcoming router.get('/upcoming', authenticate, async (req, res) => { try { - const data = await tmdb('/movie/upcoming'); - res.json(data.results.slice(0, 10)); + const [listData, genreMap] = await Promise.all([ + tmdb('/movie/upcoming'), + getGenreMap(), + ]); + const filtered = listData.results + .filter(m => m.original_language !== 'tr') + .slice(0, 10); + + 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); } catch (e) { res.status(500).json({ error: e.message }); } }); +// GET /api/tools/media/movie/:id – Details + 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, + 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, + vote_count: detail.vote_count, + genres: (detail.genres || []).map(g => g.name), + fsk: extractFSK(detail.release_dates), + cast, + director, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/tools/media/favorites router.get('/favorites', authenticate, (req, res) => { const rows = db.prepare( - 'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY added_at DESC' + 'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC' ).all(req.user.id); res.json(rows); }); +// POST /api/tools/media/favorites router.post('/favorites', authenticate, (req, res) => { - const { tmdb_id, title, poster_path, release_date_de } = req.body; + 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) VALUES (?,?,?,?,?)' - ).run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||''); + `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 }); } }); +// DELETE /api/tools/media/favorites/:tmdb_id 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 }); }); +// GET /api/tools/media/settings router.get('/settings', authenticate, requireAdmin, (req, res) => { - const token = getToken(); - res.json({ configured: !!token }); + res.json({ configured: !!getToken() }); }); +// PUT /api/tools/media/settings 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 res.json({ ok: true }); }); diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 2115b75..c4a259f 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -1,29 +1,42 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { S, api } from '../lib.js'; -const POSTER = 'https://image.tmdb.org/t/p/w342'; +const POSTER = 'https://image.tmdb.org/t/p/w342'; +const BACKDROP = 'https://image.tmdb.org/t/p/w780'; const TOOL_DEFS = [ - { id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino – Top 10 (DE)', ready: true }, - { id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino – Vorschau (DE)', ready: true }, + { id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino – Top 10 (DE)', ready: true }, + { id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino – Vorschau (DE)', ready: true }, { id: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true }, ]; const getIcon = lbl => [...lbl][0] ?? '🎬'; const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() || lbl; }; -export default function Media({ toast, mobile, nav }) { +const fmtDE = d => d ? new Date(d).toLocaleDateString('de-DE', + { day:'2-digit', month:'2-digit', year:'numeric' }) : ''; + +const FSK_COLOR = fsk => { + if (!fsk) return null; + const n = parseInt(fsk); + if (n === 0) return { bg:'#fff', color:'#000' }; + if (n === 6) return { bg:'#f9e200', color:'#000' }; + if (n === 12) return { bg:'#00a859', color:'#fff' }; + if (n === 16) return { bg:'#0070bb', color:'#fff' }; + if (n === 18) return { bg:'#e2001a', color:'#fff' }; + return { bg:'#444', color:'#fff' }; +}; + +export default function Media({ toast, mobile }) { const [activeTool, setActiveTool] = useState('kino'); + const [modal, setModal] = useState(null); // tmdb_id const chipScrollRef = useRef(null); const [chipScroll, setChipScroll] = useState({ left: false, right: true }); const onChipScroll = () => { const el = chipScrollRef.current; if (!el) return; - setChipScroll({ - left: el.scrollLeft > 4, - right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4, - }); + setChipScroll({ left: el.scrollLeft > 4, right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4 }); }; const active = TOOL_DEFS.find(t => t.id === activeTool); @@ -42,7 +55,7 @@ export default function Media({ toast, mobile, nav }) { {chipScroll.left && ( + borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>‹ )}