feat: Trending+Provider-Check für frische Hits, Fallback bei kleinen Katalogen (RTL+), 6h Cache

This commit is contained in:
2026-06-19 13:47:57 +02:00
parent 4cec47b083
commit 3e9b8b531f

View File

@@ -534,12 +534,50 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId);
if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' });
// Ergebnis 6h cachen die Trending+Provider-Checks machen mehrere
// zusätzliche TMDb-Calls, das muss nicht bei jedem Seitenaufruf neu laufen
const streamCacheKey = `streaming-top10:${provider.id}`;
const cachedResult = cacheGet(streamCacheKey);
if (cachedResult !== undefined) return res.json(cachedResult);
// Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1) // Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1)
const minYear = new Date().getFullYear() - 1; const minYear = new Date().getFullYear() - 1;
const minDate = `${minYear}-01-01`; const minDate = `${minYear}-01-01`;
// Höhere vote_count-Schwelle filtert Nischentitel jeder Herkunft heraus // ── Strategie 1: TMDb "trending diese Woche" + Provider-Check pro Titel ──
// /trending unterstützt KEINEN watch_provider-Filter, deshalb: erst die
// wöchentlich angesagten Titel holen (kleine Liste, ~20 Treffer), dann für
// jeden einzeln per /watch/providers prüfen ob er beim gewählten Anbieter
// in DE verfügbar ist. So tauchen frische virale Hits (z.B. ein Titel der
// gerade erst diese Woche durchstartet) sofort auf, statt erst Wochen
// später über die reine Gesamt-Popularität reinzurutschen.
async function getTrendingForProvider(mediaKind) {
try {
const path = mediaKind === 'movie' ? '/trending/movie/week' : '/trending/tv/week';
const trending = await tmdb(path, {});
const candidates = (trending.results || []).slice(0, 20);
const checked = await Promise.all(candidates.map(async item => {
try {
const provPath = mediaKind === 'movie'
? `/movie/${item.id}/watch/providers`
: `/tv/${item.id}/watch/providers`;
const provData = await tmdb(provPath, {});
const deFlat = provData.results?.DE?.flatrate || [];
const matchIds = provider.tmdbId.split('|').map(Number);
const available = deFlat.some(p => matchIds.includes(p.provider_id));
return available ? item : null;
} catch { return null; }
}));
return checked.filter(Boolean);
} catch { return []; } // Trending-Strategie ist nur eine Ergänzung bei Fehler einfach leer
}
// Höhere vote_count-Schwelle filtert Nischentitel jeder Herkunft heraus.
// Bei kleineren Katalogen (z.B. RTL+) reicht das oft nicht für 10 Treffer
// dann wird mit niedrigerer Schwelle nachgeladen (siehe fetchWithFallback unten).
const MIN_VOTES = 300; const MIN_VOTES = 300;
const FALLBACK_MIN_VOTES = 50;
// Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt // Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt
// erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen // erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen
@@ -550,31 +588,65 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const MAX_PER_OTHER_LANGUAGE = 2; const MAX_PER_OTHER_LANGUAGE = 2;
const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug" const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug"
const movieParams = { function buildParams(mediaKind, minVotes) {
return {
watch_region: 'DE', watch_region: 'DE',
with_watch_providers: provider.tmdbId, with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate', with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc', sort_by: 'popularity.desc',
'vote_count.gte': MIN_VOTES, 'vote_count.gte': minVotes,
'primary_release_date.gte': minDate, ...(mediaKind === 'movie'
}; ? { 'primary_release_date.gte': minDate }
const tvParams = { : { 'first_air_date.gte': minDate }),
watch_region: 'DE',
with_watch_providers: provider.tmdbId,
with_watch_monetization_types: 'flatrate',
sort_by: 'popularity.desc',
'vote_count.gte': MIN_VOTES,
'first_air_date.gte': minDate,
}; };
}
// 3 Seiten (≈60 Titel) holen, damit nach der Filterung noch genug übrig bleibt async function fetchThreePages(mediaKind, minVotes) {
const [movieP1, movieP2, movieP3, tvP1, tvP2, tvP3, genreMap] = await Promise.all([ const params = buildParams(mediaKind, minVotes);
tmdb('/discover/movie', { ...movieParams, page: 1 }), const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
tmdb('/discover/movie', { ...movieParams, page: 2 }), const [p1, p2, p3] = await Promise.all([
tmdb('/discover/movie', { ...movieParams, page: 3 }), tmdb(path, { ...params, page: 1 }),
tmdb('/discover/tv', { ...tvParams, page: 1 }), tmdb(path, { ...params, page: 2 }),
tmdb('/discover/tv', { ...tvParams, page: 2 }), tmdb(path, { ...params, page: 3 }),
tmdb('/discover/tv', { ...tvParams, page: 3 }), ]);
return [...(p1.results||[]), ...(p2.results||[]), ...(p3.results||[])];
}
// Erst Trending+Provider-Check (frische Hits), dann Discover mit hoher
// Schwelle, bei zu wenig Ergebnissen mit niedrigerer Schwelle nachladen
async function fetchWithFallback(mediaKind) {
const trendingHits = await getTrendingForProvider(mediaKind);
let raw = await fetchThreePages(mediaKind, MIN_VOTES);
let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language));
// Trending-Treffer vorne einfügen (Duplikate vermeiden)
const seenIds = new Set(filtered.map(m => m.id));
const merged = [];
for (const t of trendingHits) {
if (!seenIds.has(t.id) && !BLOCKED_LANGS.has(t.original_language)) {
merged.push(t);
seenIds.add(t.id);
}
}
merged.push(...filtered);
filtered = merged;
if (filtered.length < 10) {
const fallback = await fetchThreePages(mediaKind, FALLBACK_MIN_VOTES);
for (const m of fallback) {
if (!BLOCKED_LANGS.has(m.original_language) && !seenIds.has(m.id)) {
filtered.push(m);
seenIds.add(m.id);
}
}
}
return filtered;
}
const [movieRaw, tvRaw, genreMap] = await Promise.all([
fetchWithFallback('movie'),
fetchWithFallback('tv'),
getGenreMap(), getGenreMap(),
]); ]);
@@ -606,12 +678,6 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
return picked.slice(0, 10); return picked.slice(0, 10);
} }
const movieRaw = [...(movieP1.results||[]), ...(movieP2.results||[]), ...(movieP3.results||[])]
.filter(m => !BLOCKED_LANGS.has(m.original_language));
const tvRaw = [...(tvP1.results||[]), ...(tvP2.results||[]), ...(tvP3.results||[])]
.filter(m => !BLOCKED_LANGS.has(m.original_language));
const movies = applyLanguageWeighting(movieRaw) const movies = applyLanguageWeighting(movieRaw)
.map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' }));
@@ -628,7 +694,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
origin_country: m.origin_country || [], origin_country: m.origin_country || [],
})); }));
res.json({ const result = {
provider: { id: provider.id, label: provider.label }, provider: { id: provider.id, label: provider.label },
movies, tvShows, movies, tvShows,
filterInfo: { filterInfo: {
@@ -636,7 +702,9 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
minVotes: MIN_VOTES, minVotes: MIN_VOTES,
maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE, maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE,
}, },
}); };
cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });