fix: TV-Modal, Suche/Favoriten nach Typ getrennt, Pushover korrekte Tabelle

This commit is contained in:
2026-06-18 09:08:20 +02:00
parent 3e93188c1d
commit 5c4bbaa9ab
2 changed files with 173 additions and 28 deletions

View File

@@ -361,6 +361,39 @@ router.get('/search', authenticate, async (req, res) => {
} catch(e) { res.status(500).json({ error: e.message }); } } 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 // Modal: TMDb-Daten sofort, xREL via separaten Endpoint
router.get('/movie/:id', authenticate, async (req, res) => { router.get('/movie/:id', authenticate, async (req, res) => {
try { try {
@@ -496,15 +529,19 @@ router.post('/favorites', authenticate, (req, res) => {
if (r.changes > 0) { if (r.changes > 0) {
try { try {
const pushToken = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_token'").get()?.value; // Admin-Pushover-Einstellungen aus pushover_settings Tabelle
const pushUser = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_user'").get()?.value; 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); const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id);
if (pushToken && pushUser) { if (poCfg?.app_token && poCfg?.user_key) {
fetch('https://api.pushover.net/1/messages.json', { fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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`, message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`,
title: '⭐ Neue Favoriten', title: '⭐ Neue Favoriten',
priority: 0, priority: 0,

View File

@@ -40,7 +40,7 @@ const FSK_COLOR = fsk => {
export default function Media({ toast, mobile }) { export default function Media({ toast, mobile }) {
const [activeTool, setActiveTool] = useState('kino'); 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 [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases
const chipScrollRef = useRef(null); const chipScrollRef = useRef(null);
@@ -134,7 +134,8 @@ export default function Media({ toast, mobile }) {
{modal && createPortal( {modal && createPortal(
<MovieModal <MovieModal
tmdbId={modal} tmdbId={modal?.id || modal}
mediaType={modal?.media_type || 'movie'}
onClose={() => setModal(null)} onClose={() => setModal(null)}
mobile={mobile} mobile={mobile}
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))} 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', borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer',
}}> }}>
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }} <div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
onClick={() => onOpenModal(movie.id)}> onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
{movie.poster_path {movie.poster_path
? <img src={POSTER + movie.poster_path} alt={movie.title} ? <img src={POSTER + movie.poster_path} alt={movie.title}
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} /> style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
@@ -212,7 +213,7 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) {
}}>{isFav ? '❤' : '🤍'}</button> }}>{isFav ? '❤' : '🤍'}</button>
</div> </div>
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }} <div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
onClick={() => onOpenModal(movie.id)}> onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
{isTop && ( {isTop && (
<div style={{ fontSize:9, color:'#ffd700', fontFamily:'monospace', fontWeight:700, letterSpacing:1 }}> <div style={{ fontSize:9, color:'#ffd700', fontFamily:'monospace', fontWeight:700, letterSpacing:1 }}>
TOP {movie.rank <= 10 ? '10' : ''} TOP {movie.rank <= 10 ? '10' : ''}
@@ -459,17 +460,76 @@ function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) {
)} )}
{results !== null && results.length === 0 && ( {results !== null && results.length === 0 && (
<StatusBox>Keine Filme gefunden für {query}"</StatusBox> <StatusBox>Keine Ergebnisse für {query}"</StatusBox>
)} )}
{results !== null && results.length > 0 && (() => {
{results !== null && results.length > 0 && ( const films = results.filter(m => m.media_type !== 'tv');
const serien = results.filter(m => m.media_type === 'tv');
return (
<div>
{films.length > 0 && (
<div style={{marginBottom:20}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:8}}>🎬 FILME ({films.length})</div>
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}> <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
{results.map(m => ( {films.map(m => <MovieCard key={'m'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
<MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} </div>
onToggleFav={onToggle} onOpenModal={onOpenModal} />
))}
</div> </div>
)} )}
{serien.length > 0 && (
<div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:8}}>📺 SERIEN ({serien.length})</div>
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
{serien.map(m => <MovieCard key={'t'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
</div>
</div>
)}
</div>
);
})()}
</div>
);
}
// ── 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 (
<div onClick={() => 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',
}}>
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
{f.poster_path
? <img src={POSTER + f.poster_path} alt={f.title} style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center', justifyContent:'center', fontSize:18 }}>
{f.media_type==='tv'?'📺':'🎬'}
</div>
}
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
<div style={{ fontSize:10, color:'rgba(255,255,255,0.4)', marginTop:3, fontFamily:'monospace' }}>
🇩🇪 {f.release_date_de || ''}
</div>
{genres.length > 0 && (
<div style={{ fontSize:10, color:'#4ecdc4', marginTop:2, fontFamily:'monospace' }}>
{genres.slice(0,3).join(' · ')}
</div>
)}
{f.fsk && (
<span style={{ ...fskStyle, fontSize:9, padding:'1px 5px', borderRadius:3, marginTop:4, display:'inline-block' }}>
FSK {f.fsk}
</span>
)}
</div>
<button onClick={e=>{ e.stopPropagation(); onRemove(f.tmdb_id); }} style={{
background:'rgba(248,113,113,0.1)', border:'1px solid rgba(248,113,113,0.25)',
borderRadius:5, padding:'3px 8px', color:'#f87171', fontFamily:'monospace',
fontSize:10, cursor:'pointer', flexShrink:0,
}}>✕</button>
</div> </div>
); );
} }
@@ -606,14 +666,23 @@ function FavoritenKalender({ onOpenModal }) {
</div> </div>
); );
const groups = {}; // Aufteilen in Filme und Serien, dann nach Datum gruppieren
for (const f of favs) { 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 d = f.release_date ? new Date(f.release_date) : null;
const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt'; 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'; const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt';
if (!groups[key]) groups[key] = { label, items:[] }; if (!g[key]) g[key] = { label, items:[] };
groups[key].items.push(f); g[key].items.push(f);
} }
return g;
}
const filmGroups = buildGroups(filmeFavs);
const serienGroups = buildGroups(serienFavs);
return ( return (
<div style={{ display:'flex', flexDirection:'column', gap:24 }}> <div style={{ display:'flex', flexDirection:'column', gap:24 }}>
@@ -631,7 +700,46 @@ function FavoritenKalender({ onOpenModal }) {
)} )}
</button> </button>
)} )}
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( {/* Filme */}
{filmeFavs.length > 0 && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,fontWeight:700,letterSpacing:1,
borderBottom:'1px solid rgba(255,255,255,0.07)',paddingBottom:6}}>
🎬 FILME ({filmeFavs.length})
</div>
)}
{Object.entries(filmGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
<div key={'film-'+key}>
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
<span>{group.label.toUpperCase()}</span>
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
<span style={{ fontSize:9 }}>{group.items.length} Film{group.items.length!==1?'e':''}</span>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
</div>
</div>
))}
{/* Serien */}
{serienFavs.length > 0 && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,fontWeight:700,letterSpacing:1,
borderBottom:'1px solid rgba(255,255,255,0.07)',paddingBottom:6,marginTop:8}}>
📺 SERIEN ({serienFavs.length})
</div>
)}
{Object.entries(serienGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
<div key={'serie-'+key}>
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
<span>{group.label.toUpperCase()}</span>
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
<span style={{ fontSize:9 }}>{group.items.length} Serie{group.items.length!==1?'n':''}</span>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
</div>
</div>
))}
{/* Alte groups-Schleife entfernt DUMMY damit Code nicht bricht */}
{false && Object.entries({}).map(([key, group]) => (
<div key={key}> <div key={key}>
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}> <div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
<span>{group.label.toUpperCase()}</span> <span>{group.label.toUpperCase()}</span>
@@ -643,7 +751,7 @@ function FavoritenKalender({ onOpenModal }) {
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })(); const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
const fskStyle = FSK_COLOR(f.fsk); const fskStyle = FSK_COLOR(f.fsk);
return ( return (
<div key={f.tmdb_id} onClick={() => onOpenModal(f.tmdb_id)} style={{ <div key={f.tmdb_id} onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{
display:'flex', gap:12, alignItems:'flex-start', display:'flex', gap:12, alignItems:'flex-start',
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
borderRadius:8, padding:'10px 12px', cursor:'pointer', borderRadius:8, padding:'10px 12px', cursor:'pointer',
@@ -686,7 +794,7 @@ function FavoritenKalender({ onOpenModal }) {
} }
// ── Film-Detail-Modal (via Portal) ──────────────────────────────────────────── // ── 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 [detail, setDetail] = useState(null);
const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht
const [xrelDates, setXrelDates] = useState([]); const [xrelDates, setXrelDates] = useState([]);
@@ -700,7 +808,7 @@ function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) {
useEffect(() => { useEffect(() => {
setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError(''); setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError('');
detailRef.current = null; detailRef.current = null;
api(`/tools/media/movie/${tmdbId}`) api(`/tools/media/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}`)
.then(d => { .then(d => {
setDetail(d); setDetail(d);
detailRef.current = d; detailRef.current = d;