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);
|
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 }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user