From 3e9b8b531f5af31361e2f171726c0187250caf56 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 19 Jun 2026 13:47:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Trending+Provider-Check=20f=C3=BCr=20fr?= =?UTF-8?q?ische=20Hits,=20Fallback=20bei=20kleinen=20Katalogen=20(RTL+),?= =?UTF-8?q?=206h=20Cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/media/routes.js | 134 ++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 33 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index db10161..deb2f33 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -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 }); } });