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); 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 streamCacheKey = `streaming-top10:${provider.id}`;
const cachedResult = cacheGet(streamCacheKey); const cachedResult = cacheGet(streamCacheKey);
if (cachedResult !== undefined) return res.json(cachedResult); 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 minYear = new Date().getFullYear() - 1;
const minDate = `${minYear}-01-01`; const minDate = `${minYear}-01-01`;
// ── Strategie 1: TMDb "trending diese Woche" + Provider-Check pro Titel ── // Deutsch & Englisch sind unbegrenzt erlaubt. Alle anderen Sprachen
// /trending unterstützt KEINEN watch_provider-Filter, deshalb: erst die // zusammen (Koreanisch, Japanisch, Spanisch, etc.) dürfen max. 2 Plätze
// wöchentlich angesagten Titel holen (kleine Liste, ~20 Treffer), dann für // in den Top 10 belegen KEINE Ausnahme mehr für "hohe vote_count",
// jeden einzeln per /watch/providers prüfen ob er beim gewählten Anbieter // weil genau das die Liste mit populären Anime/K-Dramen geflutet hat.
// 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.
const UNLIMITED_LANGS = new Set(['de', 'en']); const UNLIMITED_LANGS = new Set(['de', 'en']);
const MAX_PER_OTHER_LANGUAGE = 2; const MAX_OTHER_LANGUAGE_TOTAL = 2;
const CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug"
function buildParams(mediaKind, minVotes) { function buildParams(mediaKind, minVotes) {
return { 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 params = buildParams(mediaKind, minVotes);
const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv';
const [p1, p2, p3] = await Promise.all([ const calls = [];
tmdb(path, { ...params, page: 1 }), for (let p = 1; p <= pageCount; p++) calls.push(tmdb(path, { ...params, page: p }));
tmdb(path, { ...params, page: 2 }), const pages = await Promise.all(calls);
tmdb(path, { ...params, page: 3 }), return pages.flatMap(p => p.results || []);
]);
return [...(p1.results||[]), ...(p2.results||[]), ...(p3.results||[])];
} }
// Erst Trending+Provider-Check (frische Hits), dann Discover mit hoher // Robuster Fallback in drei Stufen, garantiert genug Kandidaten:
// Schwelle, bei zu wenig Ergebnissen mit niedrigerer Schwelle nachladen // 1) hohe Schwelle (bekannte Titel), 2) mittlere, 3) niedrige (kleine Kataloge wie RTL+)
async function fetchWithFallback(mediaKind) { async function fetchCandidates(mediaKind) {
const trendingHits = await getTrendingForProvider(mediaKind); let raw = await fetchPages(mediaKind, 300, 2);
let raw = await fetchThreePages(mediaKind, MIN_VOTES);
let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language)); let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language));
if (filtered.length < 15) {
// Trending-Treffer vorne einfügen (Duplikate vermeiden) const more = await fetchPages(mediaKind, 50, 2);
const seenIds = new Set(filtered.map(m => m.id)); const seen = new Set(filtered.map(m => m.id));
const merged = []; for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); }
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) { if (filtered.length < 10) {
const fallback = await fetchThreePages(mediaKind, FALLBACK_MIN_VOTES); const more = await fetchPages(mediaKind, 0, 2);
for (const m of fallback) { const seen = new Set(filtered.map(m => m.id));
if (!BLOCKED_LANGS.has(m.original_language) && !seenIds.has(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); }
filtered.push(m);
seenIds.add(m.id);
}
}
} }
return filtered; return filtered;
} }
const [movieRaw, tvRaw, genreMap] = await Promise.all([ const [movieRaw, tvRaw, genreMap] = await Promise.all([
fetchWithFallback('movie'), fetchCandidates('movie'),
fetchWithFallback('tv'), fetchCandidates('tv'),
getGenreMap(), getGenreMap(),
]); ]);
// Liste bleibt nach Popularität sortiert. Deutsch/Englisch immer erlaubt. // Liste bleibt nach Popularität sortiert. DE/EN unbegrenzt, alle anderen
// Andere Sprachen: max. 2 Plätze, außer der Titel ist ein nachweislicher // Sprachen zusammen maximal 2 Plätze in den Top 10.
// Crossover-Hit (sehr hohe vote_count) der zählt dann nicht gegen den Deckel.
function applyLanguageWeighting(rawList) { function applyLanguageWeighting(rawList) {
const otherLangCount = {}; let otherCount = 0;
const picked = []; const picked = [];
const overflow = []; const overflow = [];
for (const item of rawList) { for (const item of rawList) {
const lang = item.original_language || 'xx'; const lang = item.original_language || 'xx';
const isUnlimited = UNLIMITED_LANGS.has(lang); if (UNLIMITED_LANGS.has(lang)) {
const isCrossoverHit = (item.vote_count || 0) >= CROSSOVER_HIT_VOTE_THRESHOLD;
if (isUnlimited || isCrossoverHit) {
picked.push(item); picked.push(item);
} else if ((otherLangCount[lang] || 0) < MAX_PER_OTHER_LANGUAGE) { } else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) {
picked.push(item); picked.push(item);
otherLangCount[lang] = (otherLangCount[lang] || 0) + 1; otherCount++;
} else { } else {
overflow.push(item); overflow.push(item);
} }
if (picked.length >= 10) break; 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) { for (const item of overflow) {
if (picked.length >= 10) break; if (picked.length >= 10) break;
picked.push(item); picked.push(item);
@@ -697,11 +641,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
const result = { const result = {
provider: { id: provider.id, label: provider.label }, provider: { id: provider.id, label: provider.label },
movies, tvShows, movies, tvShows,
filterInfo: { filterInfo: { minYear, maxOtherLanguageTotal: MAX_OTHER_LANGUAGE_TOTAL },
minYear,
minVotes: MIN_VOTES,
maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE,
},
}; };
cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache
res.json(result); res.json(result);

View File

@@ -478,7 +478,7 @@ function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp,
<div> <div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}> <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}>
Top 10 Filme &amp; Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität (Näherung, keine offiziellen Anbieter-Daten)<br/> Top 10 Filme &amp; Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität (Näherung, keine offiziellen Anbieter-Daten)<br/>
Nur wirklich bekannte Titel · Deutsch &amp; Englisch unbegrenzt, andere Sprachen max. 2 Plätze echte Hits wie Squid Game" rutschen trotzdem rein Deutsch &amp; Englisch unbegrenzt · alle anderen Sprachen zusammen max. 2 Plätze in den Top 10
</div> </div>
{/* Anbieter-Auswahl */} {/* Anbieter-Auswahl */}
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}> <div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>