feat: xREL on-demand per Button im Modal, Badge nach Fund, kein Auto-Check mehr

This commit is contained in:
2026-06-11 01:50:55 +02:00
parent 8cc0134059
commit 26059556ac
2 changed files with 67 additions and 119 deletions

View File

@@ -244,14 +244,20 @@ router.get('/movie/:id', authenticate, async (req, res) => {
} catch(e) { res.status(500).json({ error: e.message }); }
});
// xREL separat lädt im Hintergrund, cached per tmdb_id
// xREL on-demand ?refresh=1 ignoriert den Cache (erneute Suche)
router.get('/movie/:id/xrel', authenticate, async (req, res) => {
try {
const cacheKey = `xrel:tmdb:${req.params.id}`;
const forceRefresh = req.query.refresh === '1';
if (!forceRefresh) {
const cached = cacheGet(cacheKey);
if (cached !== undefined) return res.json({ xrel: cached, has_xrel: cached.length > 0 });
} else {
// Cache-Eintrag löschen für diesen Film
db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey);
}
// Brauchen Titel + IMDb-ID
const [detail, extIds] = await Promise.all([
tmdb(`/movie/${req.params.id}`, { append_to_response: '' }).catch(()=>({})),
tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})),
@@ -262,23 +268,7 @@ router.get('/movie/:id/xrel', authenticate, async (req, res) => {
} catch(e) { res.status(500).json({ error: e.message }); }
});
// Badge-Check: bis zu 3 Filme parallel, cached in xrel:tmdb:ID
router.post('/xrel-check', authenticate, async (req, res) => {
const movies = (req.body.movies||[]).slice(0,3);
if (!movies.length) return res.json({});
const result = {};
await Promise.all(movies.map(async m => {
const cacheKey = `xrel:tmdb:${m.id}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) { result[m.id] = cached.length > 0; return; }
try {
const xrel = await getXrelData(m.id, m.imdb_id||null, m.title, m.original_title);
result[m.id] = xrel.length > 0;
} catch(_) { result[m.id] = false; }
}));
res.json(result);
});
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));
@@ -304,8 +294,6 @@ router.put('/settings', authenticate, requireAdmin, (req, res) => {
genreCache = null;
res.json({ ok: true });
});
router.delete('/xrel-cache', authenticate, requireAdmin, (req, res) => {
res.json({ deleted: db.prepare('DELETE FROM xrel_cache').run().changes });
});
module.exports = router;

View File

@@ -40,16 +40,8 @@ const FSK_COLOR = fsk => {
export default function Media({ toast, mobile }) {
const [activeTool, setActiveTool] = useState('kino');
const [modal, setModal] = useState(null);
const [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases
// Admin-Status aus JWT lesen
const isAdmin = (() => {
try {
const token = localStorage.getItem('sk_token');
if (!token) return false;
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.role === 'admin';
} catch(_) { return false; }
})();
const chipScrollRef = useRef(null);
const [chipScroll, setChipScroll] = useState({ left: false, right: true });
@@ -126,40 +118,25 @@ export default function Media({ toast, mobile }) {
</div>
)}
{isAdmin && (
<div style={{ display:'flex', justifyContent:'flex-end', marginBottom:8 }}>
<button onClick={async () => {
if (!confirm('xREL-Cache leeren?')) return;
try {
const r = await fetch('/api/tools/media/xrel-cache', {
method: 'DELETE',
headers: { Authorization: 'Bearer ' + localStorage.getItem('sk_token') }
});
const d = await r.json();
toast(`Cache geleert (${d.deleted} Einträge) ✓`);
} catch(e) { toast('Fehler: ' + e.message, 'error'); }
}} style={{
fontSize: 10, fontFamily: 'monospace', cursor: 'pointer',
background: 'rgba(255,107,157,0.1)', border: '1px solid rgba(255,107,157,0.25)',
color: 'rgba(255,107,157,0.7)', borderRadius: 6, padding: '4px 10px',
}}>
🗑 xREL-Cache leeren
</button>
</div>
)}
<div style={{ ...S.card }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:18, flexWrap:'wrap' }}>
<div style={{ ...S.head, marginBottom:0 }}>{active?.label}</div>
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>{active?.desc}</span>
</div>
{activeTool === 'kino' && <KinoGrid onOpenModal={setModal} />}
{activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} />}
{activeTool === 'kino' && <KinoGrid onOpenModal={setModal} xrelIds={xrelIds} />}
{activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} xrelIds={xrelIds} />}
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
</div>
{modal && createPortal(
<MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />,
<MovieModal
tmdbId={modal}
onClose={() => setModal(null)}
mobile={mobile}
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))}
/>,
document.body
)}
</div>
@@ -167,11 +144,10 @@ export default function Media({ toast, mobile }) {
}
// ── Film-Karte ────────────────────────────────────────────────────────────────
function MovieCard({ movie, favIds, xrelIds, onToggleFav, onOpenModal }) {
function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) {
const isFav = favIds.has(movie.id);
const isTop = movie.rank != null && movie.rank <= 10;
const fskStyle = FSK_COLOR(movie.fsk);
const hasXrel = xrelIds?.has(movie.id);
return (
<div style={{
@@ -280,11 +256,10 @@ function useFavIds() {
}
// ── Kino Grid alle laufenden Filme ─────────────────────────────────────────
function KinoGrid({ onOpenModal }) {
function KinoGrid({ onOpenModal, xrelIds }) {
const [movies, setMovies] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [xrelIds, setXrelIds] = useState(new Set());
const [favIds, toggleFav] = useFavIds();
// Filme laden
@@ -297,24 +272,6 @@ function KinoGrid({ onOpenModal }) {
}, []);
// xREL-Badges: startet einmalig wenn Filme geladen, ID-String als stable dependency
const movieIds = movies.map(m => m.id).join(',');
useEffect(() => {
if (!movieIds) return;
const moviesMeta = movies.map(m => ({ id: m.id, title: m.title, original_title: m.original_title }));
let active = true;
(async () => {
for (let bi = 0; bi < moviesMeta.length; bi += 3) {
if (!active) break;
const chunk = moviesMeta.slice(bi, bi + 3);
try {
const result = await api('/tools/media/xrel-check', { method:'POST', body:{ movies: chunk } });
if (!active) break;
chunk.forEach(m => { if (result[m.id]) setXrelIds(prev => new Set([...prev, m.id])); });
} catch (_) {}
}
})();
return () => { active = false; };
}, [movieIds]);
const onToggle = async (movie, isFav) => {
try { await toggleFav(movie, isFav); } catch (e) { alert(e.message); }
@@ -333,7 +290,7 @@ function KinoGrid({ onOpenModal }) {
<>
<div style={{ ...S.head, marginBottom:12 }}> TOP 10 MEISTGESEHEN</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12, marginBottom:24 }}>
{top10.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} xrelIds={xrelIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
{top10.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</>
)}
@@ -341,7 +298,7 @@ function KinoGrid({ onOpenModal }) {
<>
<div style={{ ...S.head, marginBottom:12 }}>WEITERE FILME IM KINO</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
{rest.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} xrelIds={xrelIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
{rest.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</>
)}
@@ -350,11 +307,10 @@ function KinoGrid({ onOpenModal }) {
}
// ── Demnächst wochenweise Neustarts ────────────────────────────────────────
function DemnächstGrid({ onOpenModal }) {
function DemnächstGrid({ onOpenModal, xrelIds }) {
const [groups, setGroups] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [xrelIds, setXrelIds] = useState(new Set());
const [favIds, toggleFav] = useFavIds();
useEffect(() => {
@@ -365,25 +321,6 @@ function DemnächstGrid({ onOpenModal }) {
.finally(() => setLoading(false));
}, []);
const groupIds = Object.values(groups).flat().map(m => m.id).join(',');
useEffect(() => {
if (!groupIds) return;
const allMovies = Object.values(groups).flat();
const moviesMeta = allMovies.map(m => ({ id: m.id, title: m.title, original_title: m.original_title }));
let active = true;
(async () => {
for (let bi = 0; bi < moviesMeta.length; bi += 3) {
if (!active) break;
const chunk = moviesMeta.slice(bi, bi + 3);
try {
const result = await api('/tools/media/xrel-check', { method:'POST', body:{ movies: chunk } });
if (!active) break;
chunk.forEach(m => { if (result[m.id]) setXrelIds(prev => new Set([...prev, m.id])); });
} catch (_) {}
}
})();
return () => { active = false; };
}, [groupIds]);
const onToggle = async (movie, isFav) => {
try { await toggleFav(movie, isFav); } catch (e) { alert(e.message); }
@@ -405,7 +342,7 @@ function DemnächstGrid({ onOpenModal }) {
<span style={{ fontSize:9 }}>{movies.length} Film{movies.length!==1?'e':''}</span>
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
{movies.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} xrelIds={xrelIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
{movies.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</div>
))}
@@ -501,28 +438,34 @@ function FavoritenKalender({ onOpenModal }) {
}
// ── Film-Detail-Modal (via Portal) ────────────────────────────────────────────
function MovieModal({ tmdbId, onClose, mobile }) {
function MovieModal({ tmdbId, onClose, mobile, onHasXrel }) {
const [detail, setDetail] = useState(null);
const [xrel, setXrel] = useState(null); // null=lädt, []+=fertig
const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht
const [xrelLoading, setXrelLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true); setDetail(null); setXrel(null); setError('');
// TMDb-Daten sofort laden und anzeigen
api(`/tools/media/movie/${tmdbId}`)
.then(d => { setDetail(d); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
// xREL separat und unabhängig nachladen
api(`/tools/media/movie/${tmdbId}/xrel`)
.then(d => setXrel(d.xrel || []))
.catch(() => setXrel([]));
// Body-Scroll sperren
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [tmdbId]);
const searchXrel = async (refresh = false) => {
setXrelLoading(true);
try {
const url = `/tools/media/movie/${tmdbId}/xrel${refresh ? '?refresh=1' : ''}`;
const d = await api(url);
setXrel(d.xrel || []);
if (d.has_xrel) onHasXrel?.(tmdbId);
} catch(_) { setXrel([]); }
finally { setXrelLoading(false); }
};
// ESC schließt
useEffect(() => {
const h = e => { if (e.key === 'Escape') onClose(); };
@@ -657,13 +600,30 @@ function MovieModal({ tmdbId, onClose, mobile }) {
</div>
{/* xREL Releases */}
{xrel === null && (
<div style={{ marginTop:16, fontSize:11, color:'rgba(255,255,255,0.3)',
fontFamily:'monospace', fontStyle:'italic' }}> Suche deutsche Releases</div>
)}
{xrel !== null && xrel.length > 0 && (
<div style={{ marginTop:18 }}>
<div style={{ ...S.head, marginBottom:8 }}>RELEASES (XREL.TO)</div>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom: xrel?.length ? 8 : 0 }}>
<div style={{ ...S.head, marginBottom:0, flex:1 }}>RELEASES (XREL.TO)</div>
<button
onClick={() => searchXrel(xrel !== null)}
disabled={xrelLoading}
style={{
fontSize:10, fontFamily:'monospace', cursor: xrelLoading ? 'default' : 'pointer',
background:'rgba(78,205,196,0.1)', border:'1px solid rgba(78,205,196,0.25)',
color: xrelLoading ? 'rgba(255,255,255,0.3)' : '#4ecdc4',
borderRadius:6, padding:'3px 10px', flexShrink:0,
}}
>
{xrelLoading ? '⏳ Suche…' : xrel === null ? '🔍 Suchen' : '🔄 Aktualisieren'}
</button>
</div>
{xrel !== null && xrel.length === 0 && (
<div style={{ fontSize:11, color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontStyle:'italic' }}>
Keine deutschen Releases gefunden
</div>
)}
</div>
{xrel !== null && xrel.length > 0 && (
<div style={{ marginTop:4 }}>
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
{xrel.map((r, i) => (
<a key={i} href={r.url} target="_blank" rel="noopener noreferrer" style={{