fix: Trending-Strategie entfernt (zu instabil), harte Sprachquote ohne Crossover-Ausnahme, robuster 3-Stufen-Fallback

This commit is contained in:
2026-06-19 13:57:44 +02:00
parent 3e9b8b531f
commit dbfeac5e20
2 changed files with 33 additions and 93 deletions

View File

@@ -534,8 +534,6 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId);
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);
@@ -544,49 +542,12 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const minYear = new Date().getFullYear() - 1;
const minDate = `${minYear}-01-01`;
// ── 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 FALLBACK_MIN_VOTES = 50;
// Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt
// erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen
// gilt ein Deckel von 2 Plätzen in den Top 10 AUSSER ein Titel hat eine
// sehr hohe vote_count (= ist ein echter, in DE bekannter Crossover-Hit wie
// Squid Game), dann zählt er nicht gegen den Deckel.
// Deutsch & Englisch sind unbegrenzt erlaubt. Alle anderen Sprachen
// zusammen (Koreanisch, Japanisch, Spanisch, etc.) dürfen max. 2 Plätze
// in den Top 10 belegen KEINE Ausnahme mehr für "hohe vote_count",
// weil genau das die Liste mit populären Anime/K-Dramen geflutet hat.
const UNLIMITED_LANGS = new Set(['de', 'en']);
const MAX_PER_OTHER_LANGUAGE = 2;
const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug"
const MAX_OTHER_LANGUAGE_TOTAL = 2;
function buildParams(mediaKind, minVotes) {
return {
@@ -601,76 +562,59 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
};
}
async function fetchThreePages(mediaKind, minVotes) {
async function fetchPages(mediaKind, minVotes, pageCount) {
const params = buildParams(mediaKind, minVotes);
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const [p1, p2, p3] = await Promise.all([
tmdb(path, { ...params, page: 1 }),
tmdb(path, { ...params, page: 2 }),
tmdb(path, { ...params, page: 3 }),
]);
return [...(p1.results||[]), ...(p2.results||[]), ...(p3.results||[])];
const calls = [];
for (let p = 1; p <= pageCount; p++) calls.push(tmdb(path, { ...params, page: p }));
const pages = await Promise.all(calls);
return pages.flatMap(p => p.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);
// Robuster Fallback in drei Stufen, garantiert genug Kandidaten:
// 1) hohe Schwelle (bekannte Titel), 2) mittlere, 3) niedrige (kleine Kataloge wie RTL+)
async function fetchCandidates(mediaKind) {
let raw = await fetchPages(mediaKind, 300, 2);
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);
}
if (filtered.length < 15) {
const more = await fetchPages(mediaKind, 50, 2);
const seen = new Set(filtered.map(m => m.id));
for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.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);
}
}
const more = await fetchPages(mediaKind, 0, 2);
const seen = new Set(filtered.map(m => m.id));
for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); }
}
return filtered;
}
const [movieRaw, tvRaw, genreMap] = await Promise.all([
fetchWithFallback('movie'),
fetchWithFallback('tv'),
fetchCandidates('movie'),
fetchCandidates('tv'),
getGenreMap(),
]);
// Liste bleibt nach Popularität sortiert. Deutsch/Englisch immer erlaubt.
// Andere Sprachen: max. 2 Plätze, außer der Titel ist ein nachweislicher
// Crossover-Hit (sehr hohe vote_count) der zählt dann nicht gegen den Deckel.
// Liste bleibt nach Popularität sortiert. DE/EN unbegrenzt, alle anderen
// Sprachen zusammen maximal 2 Plätze in den Top 10.
function applyLanguageWeighting(rawList) {
const otherLangCount = {};
let otherCount = 0;
const picked = [];
const overflow = [];
for (const item of rawList) {
const lang = item.original_language || 'xx';
const isUnlimited = UNLIMITED_LANGS.has(lang);
const isCrossoverHit = (item.vote_count || 0) >= CROSSOVER_HIT_VOTE_THRESHOLD;
if (isUnlimited || isCrossoverHit) {
if (UNLIMITED_LANGS.has(lang)) {
picked.push(item);
} else if ((otherLangCount[lang] || 0) < MAX_PER_OTHER_LANGUAGE) {
} else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) {
picked.push(item);
otherLangCount[lang] = (otherLangCount[lang] || 0) + 1;
otherCount++;
} else {
overflow.push(item);
}
if (picked.length >= 10) break;
}
// Falls weniger als 10 zusammenkommen (z.B. sehr kleiner Katalog),
// mit den nächstbeliebtesten auffüllen auch über den Sprachdeckel hinaus
for (const item of overflow) {
if (picked.length >= 10) break;
picked.push(item);
@@ -697,11 +641,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const result = {
provider: { id: provider.id, label: provider.label },
movies, tvShows,
filterInfo: {
minYear,
minVotes: MIN_VOTES,
maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE,
},
filterInfo: { minYear, maxOtherLanguageTotal: MAX_OTHER_LANGUAGE_TOTAL },
};
cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache
res.json(result);