fix: Trending-Strategie entfernt (zu instabil), harte Sprachquote ohne Crossover-Ausnahme, robuster 3-Stufen-Fallback
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -478,7 +478,7 @@ function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp,
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}>
|
||||
Top 10 Filme & 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 & Englisch unbegrenzt, andere Sprachen max. 2 Plätze – echte Hits wie „Squid Game" rutschen trotzdem rein
|
||||
Deutsch & Englisch unbegrenzt · alle anderen Sprachen zusammen max. 2 Plätze in den Top 10
|
||||
</div>
|
||||
{/* Anbieter-Auswahl */}
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
|
||||
|
||||
Reference in New Issue
Block a user