diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 5bc146f..c25bed6 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -361,6 +361,39 @@ router.get('/search', authenticate, async (req, res) => { } catch(e) { res.status(500).json({ error: e.message }); } }); +// TV-Serie Detail +router.get('/tv/:id', authenticate, async (req, res) => { + try { + const [detail, genreMap] = await Promise.all([ + tmdb(`/tv/${req.params.id}`, { append_to_response: 'credits,content_ratings' }), + getGenreMap(), + ]); + // 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,10).map(c=>({ name:c.name, character:c.character, profile_path:c.profile_path })), + 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 { @@ -496,15 +529,19 @@ router.post('/favorites', authenticate, (req, res) => { if (r.changes > 0) { try { - const pushToken = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_token'").get()?.value; - const pushUser = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_user'").get()?.value; - const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id); - if (pushToken && pushUser) { + // 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: pushToken, user: pushUser, + token: poCfg.app_token, + user: poCfg.user_key, message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`, title: '⭐ Neue Favoriten', priority: 0, diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 2ed1c5f..2bbb1c4 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -40,7 +40,7 @@ const FSK_COLOR = fsk => { export default function Media({ toast, mobile }) { const [activeTool, setActiveTool] = useState('kino'); - const [modal, setModal] = useState(null); + const [modal, setModal] = useState(null); // { id, media_type } const [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases const chipScrollRef = useRef(null); @@ -134,7 +134,8 @@ export default function Media({ toast, mobile }) { {modal && createPortal( setModal(null)} mobile={mobile} onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))} @@ -158,7 +159,7 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) { borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer', }}>
onOpenModal(movie.id)}> + onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}> {movie.poster_path ? {movie.title} @@ -212,7 +213,7 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) { }}>{isFav ? '❀' : '🀍'}
onOpenModal(movie.id)}> + onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}> {isTop && (
β˜… TOP {movie.rank <= 10 ? '10' : ''} @@ -459,17 +460,76 @@ function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) { )} {results !== null && results.length === 0 && ( - Keine Filme gefunden fΓΌr β€ž{query}" + Keine Ergebnisse fΓΌr β€ž{query}" )} + {results !== null && results.length > 0 && (() => { + const films = results.filter(m => m.media_type !== 'tv'); + const serien = results.filter(m => m.media_type === 'tv'); + return ( +
+ {films.length > 0 && ( +
+
🎬 FILME ({films.length})
+
+ {films.map(m => )} +
+
+ )} + {serien.length > 0 && ( +
+
πŸ“Ί SERIEN ({serien.length})
+
+ {serien.map(m => )} +
+
+ )} +
+ ); + })()} +
+ ); +} - {results !== null && results.length > 0 && ( -
- {results.map(m => ( - - ))} +// ── FavCard – einzelner Favoriten-Eintrag ───────────────────────────────────── +function FavCard({ f, onOpenModal, onRemove }) { + const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })(); + const fskStyle = FSK_COLOR(f.fsk); + return ( +
onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{ + display:'flex', gap:12, alignItems:'flex-start', + background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)', + borderRadius:8, padding:'10px 12px', cursor:'pointer', position:'relative', + }}> +
+ {f.poster_path + ? {f.title} + :
+ {f.media_type==='tv'?'πŸ“Ί':'🎬'} +
+ } +
+
+
{f.title}
+
+ πŸ‡©πŸ‡ͺ {f.release_date_de || '–'}
- )} + {genres.length > 0 && ( +
+ {genres.slice(0,3).join(' Β· ')} +
+ )} + {f.fsk && ( + + FSK {f.fsk} + + )} +
+
); } @@ -606,14 +666,23 @@ function FavoritenKalender({ onOpenModal }) {
); - const groups = {}; - for (const f of favs) { - const d = f.release_date ? new Date(f.release_date) : null; - const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt'; - const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt'; - if (!groups[key]) groups[key] = { label, items:[] }; - groups[key].items.push(f); + // Aufteilen in Filme und Serien, dann nach Datum gruppieren + const filmeFavs = favs.filter(f => f.media_type !== 'tv'); + const serienFavs = favs.filter(f => f.media_type === 'tv'); + + function buildGroups(list) { + const g = {}; + for (const f of list) { + const d = f.release_date ? new Date(f.release_date) : null; + const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt'; + const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt'; + if (!g[key]) g[key] = { label, items:[] }; + g[key].items.push(f); + } + return g; } + const filmGroups = buildGroups(filmeFavs); + const serienGroups = buildGroups(serienFavs); return (
@@ -631,7 +700,46 @@ function FavoritenKalender({ onOpenModal }) { )} )} - {Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( + {/* Filme */} + {filmeFavs.length > 0 && ( +
+ 🎬 FILME ({filmeFavs.length}) +
+ )} + {Object.entries(filmGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( +
+
+ {group.label.toUpperCase()} +
+ {group.items.length} Film{group.items.length!==1?'e':''} +
+
+ {group.items.map(f => )} +
+
+ ))} + {/* Serien */} + {serienFavs.length > 0 && ( +
+ πŸ“Ί SERIEN ({serienFavs.length}) +
+ )} + {Object.entries(serienGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( +
+
+ {group.label.toUpperCase()} +
+ {group.items.length} Serie{group.items.length!==1?'n':''} +
+
+ {group.items.map(f => )} +
+
+ ))} + {/* Alte groups-Schleife entfernt – DUMMY damit Code nicht bricht */} + {false && Object.entries({}).map(([key, group]) => (
{group.label.toUpperCase()} @@ -643,7 +751,7 @@ function FavoritenKalender({ onOpenModal }) { const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })(); const fskStyle = FSK_COLOR(f.fsk); return ( -
onOpenModal(f.tmdb_id)} style={{ +
onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{ display:'flex', gap:12, alignItems:'flex-start', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)', borderRadius:8, padding:'10px 12px', cursor:'pointer', @@ -686,7 +794,7 @@ function FavoritenKalender({ onOpenModal }) { } // ── Film-Detail-Modal (via Portal) ──────────────────────────────────────────── -function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) { +function MovieModal({ tmdbId, mediaType='movie', onClose, mobile, onHasXrel, noYearFilter }) { const [detail, setDetail] = useState(null); const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht const [xrelDates, setXrelDates] = useState([]); @@ -700,7 +808,7 @@ function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) { useEffect(() => { setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError(''); detailRef.current = null; - api(`/tools/media/movie/${tmdbId}`) + api(`/tools/media/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}`) .then(d => { setDetail(d); detailRef.current = d;