diff --git a/backend/package.json b/backend/package.json index f1d10d5..410f309 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,6 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", - "puppeteer-core": "^22.0.0" + "xlsx": "^0.18.5" } } \ No newline at end of file diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index a9615e7..5a7c9b7 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -2,11 +2,13 @@ const express = require('express'); const router = express.Router(); const db = require('../../db'); const { authenticate, requireAdmin } = require('../../middleware/auth'); +const XLSX = require('xlsx'); const TMDB_BASE = 'https://api.themoviedb.org/3'; const XREL_BASE = 'https://api.xrel.to/v2'; const THIS_YEAR = new Date().getFullYear(); const MIN_YEAR = THIS_YEAR - 1; +const NETFLIX_TOP10_URL = 'https://www.netflix.com/tudum/top10/data/all-weeks-countries.xlsx'; // Sprachen die wir NICHT wollen const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']); @@ -489,6 +491,70 @@ router.get('/xrel-cached', authenticate, (req, res) => { // ── Streaming Top 10 pro Anbieter ────────────────────────────────────────────── // TMDb Watch-Provider-IDs für Deutschland (watch_region=DE) +// ── Netflix offizielle Top-10-Daten (keine Näherung – echte Zahlen von Netflix selbst) ── +// Quelle: https://www.netflix.com/tudum/top10/data/all-weeks-countries.xlsx +// Wird wöchentlich (dienstags) von Netflix aktualisiert → 20h Cache reicht völlig +async function getNetflixTop10(category /* 'Films' | 'TV' */) { + const cacheKey = `netflix-top10:${category}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + + const resp = await fetch(NETFLIX_TOP10_URL, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DickenDock/1.0)' }, + }); + if (!resp.ok) throw new Error(`Netflix-Daten nicht erreichbar (HTTP ${resp.status})`); + const buf = await resp.arrayBuffer(); + const wb = XLSX.read(buf, { type: 'array' }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet); + + // Spalten (Stand der Netflix-Datei): country_name, country_iso2, category, week, weekly_rank, + // show_title, season_title, cumulative_weeks_in_top10, ... + const deRows = rows.filter(r => r.country_iso2 === 'DE' && r.category === category); + if (!deRows.length) { cacheSet(cacheKey, [], 60*60*1000); return []; } + + // Neueste Woche finden + const latestWeek = deRows.reduce((max, r) => (r.week > max ? r.week : max), deRows[0].week); + const top10 = deRows + .filter(r => r.week === latestWeek) + .sort((a, b) => a.weekly_rank - b.weekly_rank) + .slice(0, 10) + .map(r => ({ + rank: r.weekly_rank, + title: r.show_title, + seasonTitle: r.season_title || '', + weeksInTop10: r.cumulative_weeks_in_top10 || null, + week: r.week, + })); + + cacheSet(cacheKey, top10, 20 * 60 * 60 * 1000); // 20h Cache + return top10; +} + +// Netflix-Titel mit TMDb abgleichen (für Poster, Genres, Modal-Infos) +async function matchNetflixTitleToTmdb(title, mediaType, genreMap) { + try { + const searchPath = mediaType === 'tv' ? '/search/tv' : '/search/movie'; + const data = await tmdb(searchPath, { query: title }); + const hit = (data.results || [])[0]; + if (!hit) return null; + if (mediaType === 'tv') { + return { + id: hit.id, media_type: 'tv', + title: hit.name || hit.original_name, + original_title: hit.original_name, + release_date: hit.first_air_date || '', + poster_path: hit.poster_path || '', + genres: (hit.genre_ids||[]).map(gid => genreMap[gid]).filter(Boolean), + vote_average: hit.vote_average, + original_language: hit.original_language, + origin_country: hit.origin_country || [], + }; + } + return { ...enrichMovie(hit, genreMap, null, null), media_type: 'movie' }; + } catch { return null; } +} + const STREAMING_PROVIDERS = [ { id: 'netflix', label: 'Netflix', tmdbId: '8' }, { id: 'disney', label: 'Disney+', tmdbId: '337' }, @@ -527,19 +593,52 @@ 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' }); + // ── NETFLIX: echte offizielle Daten, KEIN Filter ───────────────────────── + if (provider.id === 'netflix') { + const genreMap = await getGenreMap(); + const [nfFilms, nfShows] = await Promise.all([ + getNetflixTop10('Films'), + getNetflixTop10('TV'), + ]); + + const movies = (await Promise.all( + nfFilms.map(async nf => { + const matched = await matchNetflixTitleToTmdb(nf.title, 'movie', genreMap); + return matched ? { ...matched, rank: nf.rank, weeksInTop10: nf.weeksInTop10, netflixTitle: nf.title } : null; + }) + )).filter(Boolean); + + const tvShows = (await Promise.all( + nfShows.map(async nf => { + const matched = await matchNetflixTitleToTmdb(nf.title, 'tv', genreMap); + return matched ? { ...matched, rank: nf.rank, weeksInTop10: nf.weeksInTop10, netflixTitle: nf.title } : null; + }) + )).filter(Boolean); + + return res.json({ + provider: { id: provider.id, label: provider.label }, + movies, tvShows, + isOfficialData: true, + filterInfo: null, // keine Filter – Original Netflix Top 10 + }); + } + // ── ENDE NETFLIX-Sonderfall ─────────────────────────────────────────────── + // 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 - // (nicht nach Sprache diskriminierend – ein Nischenfilm aus den USA fliegt genauso raus - // wie ein Nischenfilm aus Korea; nur wirklich bekannte Titel bleiben drin) const MIN_VOTES = 300; - // Zusätzliche Diversitäts-Deckelung: max. 3 Titel je Originalsprache in den Top 10, - // damit eine einzelne Produktionsregion (z.B. durch viele ähnliche K-Dramen) die Liste - // nicht komplett dominiert – ECHTE Hits (z.B. Squid Game) rutschen wegen hoher vote_count - // ohnehin weit nach oben und werden davon nicht ausgebremst. - const MAX_PER_LANGUAGE = 3; + + // 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. + 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 movieParams = { watch_region: 'DE', @@ -558,7 +657,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { 'first_air_date.gte': minDate, }; - // 3 Seiten (≈60 Titel) holen, damit nach der Diversitäts-Deckelung noch genug übrig bleibt + // 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 }), @@ -569,26 +668,27 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { getGenreMap(), ]); - // Diversitäts-Deckelung anwenden: Liste bleibt nach Popularität sortiert, - // aber sobald eine Sprache MAX_PER_LANGUAGE erreicht hat, werden weitere - // Titel dieser Sprache übersprungen (sie können trotzdem auftauchen sobald - // ein Slot durch erschöpfte andere Sprachen frei bleibt – siehe Fallback unten). - function applyDiversityCap(rawList) { - const langCount = {}; + // 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. + function applyLanguageWeighting(rawList) { + const otherLangCount = {}; const picked = []; const overflow = []; for (const item of rawList) { const lang = item.original_language || 'xx'; - if ((langCount[lang] || 0) < MAX_PER_LANGUAGE) { + const isUnlimited = UNLIMITED_LANGS.has(lang); + const isCrossoverHit = (item.vote_count || 0) >= CROSSOVER_HIT_VOTE_THRESHOLD; + if (isUnlimited || isCrossoverHit) { picked.push(item); - langCount[lang] = (langCount[lang] || 0) + 1; + } else if ((otherLangCount[lang] || 0) < MAX_PER_OTHER_LANGUAGE) { + picked.push(item); + otherLangCount[lang] = (otherLangCount[lang] || 0) + 1; } else { - overflow.push(item); // falls 10 nicht erreicht werden, später auffüllen + overflow.push(item); } if (picked.length >= 10) break; } - // Falls durch die Deckelung weniger als 10 zusammenkommen, mit den - // nächstbeliebtesten Titeln auffüllen (auch wenn Deckel überschritten wird) for (const item of overflow) { if (picked.length >= 10) break; picked.push(item); @@ -601,10 +701,11 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { const tvRaw = [...(tvP1.results||[]), ...(tvP2.results||[]), ...(tvP3.results||[])] .filter(m => !BLOCKED_LANGS.has(m.original_language)); - const movies = applyDiversityCap(movieRaw) + + const movies = applyLanguageWeighting(movieRaw) .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); - const tvShows = applyDiversityCap(tvRaw) + const tvShows = applyLanguageWeighting(tvRaw) .map((m, i) => ({ id: m.id, media_type: 'tv', rank: i + 1, title: m.name || m.original_name, @@ -620,10 +721,11 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { res.json({ provider: { id: provider.id, label: provider.label }, movies, tvShows, + isOfficialData: false, filterInfo: { minYear, minVotes: MIN_VOTES, - maxPerLanguage: MAX_PER_LANGUAGE, + maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE, }, }); } catch(e) { res.status(500).json({ error: e.message }); } diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index cf6bc0b..0fc412f 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -476,10 +476,16 @@ function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp, return (