feat: xREL on-demand per Button im Modal, Badge nach Fund, kein Auto-Check mehr
This commit is contained in:
@@ -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 cached = cacheGet(cacheKey);
|
||||
if (cached !== undefined) return res.json({ xrel: cached, has_xrel: cached.length > 0 });
|
||||
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;
|
||||
|
||||
@@ -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 }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [xrel, setXrel] = useState(null); // null=lädt, []+=fertig
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
function MovieModal({ tmdbId, onClose, mobile, onHasXrel }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
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>
|
||||
)}
|
||||
<div style={{ marginTop:18 }}>
|
||||
<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:18 }}>
|
||||
<div style={{ ...S.head, marginBottom:8 }}>RELEASES (XREL.TO)</div>
|
||||
<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={{
|
||||
|
||||
Reference in New Issue
Block a user