diff --git a/backend/src/db.js b/backend/src/db.js index 20e3b97..f332e47 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -556,6 +556,12 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='mov 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 ''"); + if (!cols.includes('acknowledged')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged INTEGER NOT NULL DEFAULT 0"); + if (!cols.includes('acknowledged_at')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged_at DATETIME DEFAULT NULL"); + if (!cols.includes('media_type')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN media_type TEXT NOT NULL DEFAULT 'movie'"); } // xREL Badge Cache (persistent, überlebt Container-Restarts) diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 1b5f68f..5262f22 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -320,4 +320,11 @@ router.delete('/push-schedules/:id', authenticate, (req, res) => { res.json({ ok: true }); }); +// Unquittierte Favoriten für Admin-Dashboard-Badge +router.get('/media-favorites/unacked', authenticate, (req, res) => { + if (req.user.role !== 'admin') return res.json({ count: 0 }); + const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get(); + res.json({ count: count.n }); +}); + module.exports = router; diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 73f17a5..becb08a 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -320,25 +320,35 @@ router.get('/search', authenticate, async (req, res) => { const searchParams = { query: q, include_adult: 'false' }; if (yearFilter) searchParams.year = yearFilter; - const [results, genreMap] = await Promise.all([ + const [movieResults, tvResults, genreMap] = await Promise.all([ tmdb('/search/movie', searchParams), + tmdb('/search/tv', { query: q, include_adult: 'false', ...(yearFilter ? { first_air_date_year: yearFilter } : {}) }), getGenreMap(), ]); const seen = new Set(); - const filtered = (results.results || []) - .filter(m => { - if (BLOCKED_LANGS.has(m.original_language)) return false; - if (seen.has(m.id)) return false; - seen.add(m.id); - return true; - }) - .slice(0, 20) - .map(m => enrichMovie(m, genreMap, null, null)); + const movies = (movieResults.results || []) + .filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; if (seen.has('m'+m.id)) return false; seen.add('m'+m.id); return true; }) + .map(m => ({ ...enrichMovie(m, genreMap, null, null), media_type: 'movie' })); - // Neueste zuerst (Backend sortiert, Frontend sortiert auch – konsistent) - filtered.sort((a,b) => (b.release_date||'').localeCompare(a.release_date||'')); - res.json(filtered); + const tvShows = (tvResults.results || []) + .filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; if (seen.has('t'+m.id)) return false; seen.add('t'+m.id); return true; }) + .map(m => ({ + id: m.id, media_type: 'tv', + title: m.name || m.original_name, + original_title: m.original_name, + release_date: m.first_air_date || '', + poster_path: m.poster_path || '', + genres: (m.genre_ids||[]).map(id => genreMap[id]).filter(Boolean), + vote_average: m.vote_average, + original_language: m.original_language, + })); + + const combined = [...movies, ...tvShows] + .sort((a,b) => (b.release_date||'').localeCompare(a.release_date||'')) + .slice(0, 30); + + res.json(combined); } catch(e) { res.status(500).json({ error: e.message }); } }); @@ -436,18 +446,71 @@ router.get('/favorites', authenticate, (req, res) => { res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id)); }); router.post('/favorites', authenticate, (req, res) => { - const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk } = req.body; + const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk, media_type } = 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,release_date,genres,fsk) VALUES (?,?,?,?,?,?,?,?)`) - .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||''); + const r = db.prepare(`INSERT OR IGNORE INTO movie_favorites + (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk,media_type,added_at) + VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) + .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||'', media_type||'movie'); + + if (r.changes > 0) { + // Pushover-Benachrichtigung an Admin (fire-and-forget, nie emergency) + 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) { + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: pushToken, user: pushUser, + message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`, + title: '⭐ Neue Favoriten', + priority: 0, + }), + }).catch(() => {}); + } + } catch {} + } res.json({ ok: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); + 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 }); }); + +// Admin: alle Favoriten aller User +router.get('/favorites/all', authenticate, requireAdmin, (req, res) => { + const favs = db.prepare(` + SELECT f.*, u.username + FROM movie_favorites f + JOIN users u ON u.id = f.user_id + ORDER BY f.acknowledged ASC, f.added_at DESC + `).all(); + res.json(favs); +}); + +// Admin: Favoritenanzahl unquittiert (für Dashboard) +router.get('/favorites/unacked', authenticate, requireAdmin, (req, res) => { + const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get(); + res.json({ count: count.n }); +}); + +// Admin: Favorit quittieren +router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => { + db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); + +// Admin: alle quittieren +router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { + db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP WHERE acknowledged=0').run(); + res.json({ ok: true }); +}); router.get('/settings', authenticate, requireAdmin, (req, res) => res.json({ configured: !!getToken() })); router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7de8e22..85a3b7f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1903,7 +1903,7 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) { } -function Dashboard({ toast, mobile, setActive, setDevToolsNav, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, unreadChangelog=0, onChangelogRead, user }) { +function Dashboard({ toast, mobile, setActive, setDevToolsNav, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, unreadChangelog=0, onChangelogRead, unackedFavs=0, onAckFavs, user }) { const [now, setNow] = useState(new Date()); const [showBoard, setShowBoard] = useState(false); const [showChangelog, setShowChangelog] = useState(false); @@ -1981,6 +1981,16 @@ function Dashboard({ toast, mobile, setActive, setDevToolsNav, unreadMsgs=0, unr borderRadius:'50%', background:'#ffe66d', boxShadow:'0 0 5px #ffe66d' }}/> )} + {/* Admin: unquittierte Favoriten */} + {user?.role==='admin' && unackedFavs > 0 && ( + + )} ); @@ -3608,6 +3618,7 @@ export default function App() { const [unreadBoard, setUnreadBoard] = useState(0); const [unreadPromoted, setUnreadPromoted] = useState(0); const [unreadChangelog,setUnreadChangelog]= useState(0); + const [unackedFavs, setUnackedFavs] = useState(0); const [authChecked, setAuthChecked] = useState(false); useEffect(()=>{ diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 6b683c8..84bc899 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -165,6 +165,14 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) { :
🎬
} + {/* TV-Badge */} + {movie.media_type === 'tv' && ( +
+ SERIE +
+ )} {/* Top-Badge */} {isTop && (
new Set([...prev, movie.id])); } @@ -422,7 +431,7 @@ function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) { value={query} onChange={e => setQuery(e.target.value)} onKeyDown={onKey} - placeholder="Filmtitel eingeben…" + placeholder="Film- oder Serientitel eingeben…" style={{ flex:1, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.15)', borderRadius:8, padding:'9px 14px', color:'#fff', fontFamily:"'Space Mono',monospace", @@ -467,15 +476,29 @@ function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) { // ── Favoriten Kalender ──────────────────────────────────────────────────────── function FavoritenKalender({ onOpenModal }) { - const [favs, setFavs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + const [favs, setFavs] = useState([]); + const [allFavs, setAllFavs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [adminTab, setAdminTab]= useState(false); + const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { setLoading(true); setError(''); - api('/tools/media/favorites').then(setFavs).catch(e => setError(e.message)).finally(() => setLoading(false)); + Promise.all([ + api('/tools/media/favorites'), + api('/tools/media/favorites/all').catch(() => null), + ]).then(([mine, all]) => { + setFavs(mine); + if (all) { setAllFavs(all); setIsAdmin(true); } + }).catch(e => setError(e.message)).finally(() => setLoading(false)); }, []); + const ackAll = async () => { + await api('/tools/media/favorites/acknowledge-all', { method:'POST', body:{} }); + setAllFavs(prev => prev.map(f => ({ ...f, acknowledged: 1 }))); + }; + const removeFav = async (tmdbId) => { try { await api(`/tools/media/favorites/${tmdbId}`, { method:'DELETE' }); @@ -485,7 +508,79 @@ function FavoritenKalender({ onOpenModal }) { if (loading) return ⏳ Lädt…; if (error) return ; - if (!favs.length) return Noch keine Favoriten – markiere Filme mit ❤; + + // Admin-Ansicht: alle Favoriten aller User + if (isAdmin && adminTab) { + const unacked = allFavs.filter(f => !f.acknowledged).length; + return ( +
+
+ + {unacked > 0 && ( + + )} +
+
+ {allFavs.map(f => ( +
+ {f.poster_path + ? {f.title} + :
+ {f.media_type==='tv'?'📺':'🎬'} +
+ } +
+
+ {f.title} + {f.media_type==='tv' && SERIE} +
+
+ 👤 {f.username} · ⭐ {f.added_at ? new Date(f.added_at).toLocaleDateString('de-DE') : '—'} + {!!f.acknowledged && ✓ quittiert} +
+
+ {!f.acknowledged && ( + + )} +
+ ))} + {!allFavs.length && Noch keine Favoriten von irgendjemandem.} +
+
+ ); + } + + if (!favs.length) return ( +
+ {isAdmin && ( + + )} + Noch keine Favoriten – markiere Filme mit ❤ +
+ ); const groups = {}; for (const f of favs) { @@ -498,6 +593,20 @@ function FavoritenKalender({ onOpenModal }) { return (
+ {isAdmin && ( + + )} {Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (