feat: Trending+Provider-Check für frische Hits, Fallback bei kleinen Katalogen (RTL+), 6h Cache
This commit is contained in:
@@ -534,12 +534,50 @@ 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);
|
||||
|
||||
// Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1)
|
||||
const minYear = new Date().getFullYear() - 1;
|
||||
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 FALLBACK_MIN_VOTES = 50;
|
||||
|
||||
// Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt
|
||||
// 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 CROSSOVER_HIT_VOTE_THRESHOLD = 3000; // ab hier zählt ein Titel als "bekannt genug"
|
||||
|
||||
const movieParams = {
|
||||
watch_region: 'DE',
|
||||
with_watch_providers: provider.tmdbId,
|
||||
with_watch_monetization_types: 'flatrate',
|
||||
sort_by: 'popularity.desc',
|
||||
'vote_count.gte': MIN_VOTES,
|
||||
'primary_release_date.gte': minDate,
|
||||
};
|
||||
const tvParams = {
|
||||
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,
|
||||
};
|
||||
function buildParams(mediaKind, minVotes) {
|
||||
return {
|
||||
watch_region: 'DE',
|
||||
with_watch_providers: provider.tmdbId,
|
||||
with_watch_monetization_types: 'flatrate',
|
||||
sort_by: 'popularity.desc',
|
||||
'vote_count.gte': minVotes,
|
||||
...(mediaKind === 'movie'
|
||||
? { 'primary_release_date.gte': minDate }
|
||||
: { 'first_air_date.gte': minDate }),
|
||||
};
|
||||
}
|
||||
|
||||
// 3 Seiten (≈60 Titel) holen, damit nach der Filterung noch genug übrig bleibt
|
||||
const [movieP1, movieP2, movieP3, tvP1, tvP2, tvP3, genreMap] = await Promise.all([
|
||||
tmdb('/discover/movie', { ...movieParams, page: 1 }),
|
||||
tmdb('/discover/movie', { ...movieParams, page: 2 }),
|
||||
tmdb('/discover/movie', { ...movieParams, page: 3 }),
|
||||
tmdb('/discover/tv', { ...tvParams, page: 1 }),
|
||||
tmdb('/discover/tv', { ...tvParams, page: 2 }),
|
||||
tmdb('/discover/tv', { ...tvParams, page: 3 }),
|
||||
async function fetchThreePages(mediaKind, minVotes) {
|
||||
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||[])];
|
||||
}
|
||||
|
||||
// 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(),
|
||||
]);
|
||||
|
||||
@@ -606,12 +678,6 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
||||
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)
|
||||
.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 || [],
|
||||
}));
|
||||
|
||||
res.json({
|
||||
const result = {
|
||||
provider: { id: provider.id, label: provider.label },
|
||||
movies, tvShows,
|
||||
filterInfo: {
|
||||
@@ -636,7 +702,9 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
||||
minVotes: MIN_VOTES,
|
||||
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 }); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user