diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index dec648b..8861c8b 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -549,33 +549,53 @@ router.get('/streaming/:providerId/new', authenticate, async (req, res) => { const cached = cacheGet(cacheKey); if (cached !== undefined) return res.json(cached); - // Enges 21-Tage-Fenster, damit "Neuerscheinungen" sich spürbar von "Top 10" - // (letzte 2 Jahre) unterscheidet, statt fast identisch zu sein - const since = (() => { - const d = new Date(); - d.setDate(d.getDate() - 21); - return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; - })(); const today = (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })(); + function daysAgo(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + } - // Innerhalb des 21-Tage-Fensters nach POPULARITÄT sortieren (nicht nach - // Datum) – sonst landen wenig beachtete Tagesveröffentlichungen vor - // tatsächlich angesagten Titeln. "Neuerscheinungen" heißt: neu UND beachtet. - function baseParams(mediaKind) { + function baseParams(mediaKind, sinceDate) { return { watch_region: 'DE', with_watch_providers: provider.tmdbId, with_watch_monetization_types: 'flatrate', sort_by: 'popularity.desc', ...(mediaKind === 'movie' - ? { 'primary_release_date.gte': since, 'primary_release_date.lte': today } - : { 'first_air_date.gte': since, 'first_air_date.lte': today }), + ? { 'primary_release_date.gte': sinceDate, 'primary_release_date.lte': today } + : { 'first_air_date.gte': sinceDate, 'first_air_date.lte': today }), }; } + async function fetchWindow(mediaKind, sinceDate) { + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const [p1, p2] = await Promise.all([ + tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 1 }), + tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 2 }), + ]); + return [...(p1.results||[]), ...(p2.results||[])].filter(m => !BLOCKED_LANGS.has(m.original_language)); + } + + // Manche Anbieter (z.B. Disney+, RTL+) haben bei TMDb nur sehr wenige Titel + // mit korrekt erfasstem Veröffentlichungsdatum + Verfügbarkeit verknüpft. + // Statt einer fast leeren Liste wird das Zeitfenster schrittweise vergrößert, + // bis genug Titel zusammenkommen – der tatsächlich verwendete Zeitraum wird + // im Ergebnis ehrlich mitgegeben (kein versteckter Fake-"neu"). + const WINDOW_STAGES = [21, 45, 90, 180]; + async function fetchCandidatesWithExpansion(mediaKind) { + let usedWindow = WINDOW_STAGES[0]; + let result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[0])); + for (let i = 1; i < WINDOW_STAGES.length && result.length < 6; i++) { + usedWindow = WINDOW_STAGES[i]; + result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[i])); + } + return { items: result, usedWindow }; + } + // Gleiche Sprachgewichtung wie bei Top 10: Deutsch/Englisch unbegrenzt, // alle anderen Sprachen zusammen max. 2 Plätze const UNLIMITED_LANGS = new Set(['de', 'en']); @@ -603,19 +623,12 @@ router.get('/streaming/:providerId/new', authenticate, async (req, res) => { return picked.slice(0, 10); } - const [movieP1, movieP2, tvP1, tvP2, genreMap] = await Promise.all([ - tmdb('/discover/movie', { ...baseParams('movie'), page: 1 }), - tmdb('/discover/movie', { ...baseParams('movie'), page: 2 }), - tmdb('/discover/tv', { ...baseParams('tv'), page: 1 }), - tmdb('/discover/tv', { ...baseParams('tv'), page: 2 }), + const [movieResult, tvResult, genreMap] = await Promise.all([ + fetchCandidatesWithExpansion('movie'), + fetchCandidatesWithExpansion('tv'), getGenreMap(), ]); - const movieRaw = [...(movieP1.results||[]), ...(movieP2.results||[])] - .filter(m => !BLOCKED_LANGS.has(m.original_language)); - const tvRaw = [...(tvP1.results||[]), ...(tvP2.results||[])] - .filter(m => !BLOCKED_LANGS.has(m.original_language)); - // Titel die bereits in der (gecachten) Top-10-Liste dieses Anbieters stehen // aus den Neuerscheinungen ausschließen, damit beide Reiter sich nicht // überschneiden, selbst wenn sich die Zeitfenster teilweise überlappen @@ -623,10 +636,10 @@ router.get('/streaming/:providerId/new', authenticate, async (req, res) => { const top10MovieIds = new Set((top10Cached?.movies || []).map(m => m.id)); const top10TvIds = new Set((top10Cached?.tvShows || []).map(m => m.id)); - const movies = applyLanguageWeighting(movieRaw.filter(m => !top10MovieIds.has(m.id))) + const movies = applyLanguageWeighting(movieResult.items.filter(m => !top10MovieIds.has(m.id))) .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); - const tvShows = applyLanguageWeighting(tvRaw.filter(m => !top10TvIds.has(m.id))) + const tvShows = applyLanguageWeighting(tvResult.items.filter(m => !top10TvIds.has(m.id))) .map((m, i) => ({ id: m.id, media_type: 'tv', rank: i + 1, title: m.name || m.original_name, @@ -639,7 +652,12 @@ router.get('/streaming/:providerId/new', authenticate, async (req, res) => { origin_country: m.origin_country || [], })); - const result = { provider: { id: provider.id, label: provider.label }, movies, tvShows, since }; + const result = { + provider: { id: provider.id, label: provider.label }, + movies, tvShows, + movieWindowDays: movieResult.usedWindow, + tvWindowDays: tvResult.usedWindow, + }; cacheSet(cacheKey, result, 6 * 60 * 60 * 1000); res.json(result); } catch(e) { res.status(500).json({ error: e.message }); } diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 9b09a52..98951c6 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -505,7 +505,10 @@ function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp, ) : (