From 03043fc8ba1092acbae9b9ff27347aa6600558cd Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 19 Jun 2026 15:55:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Netflix=20echte=20Tudum-Charts=20via=20?= =?UTF-8?q?leichtgewichtigem=20HTML-Parsing=20(keine=20XLSX),=20Fallback?= =?UTF-8?q?=20auf=20TMDb-N=C3=A4herung=20bei=20Fehler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/media/routes.js | 116 ++++++++++++++++++++++++++++++ frontend/src/tools/media.jsx | 14 ++-- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 46bf338..0f2d724 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -496,6 +496,82 @@ router.get('/xrel-cached', authenticate, (req, res) => { // Netflix läuft daher wie alle anderen Anbieter über die leichtgewichtige // TMDb-Näherung weiter unten. +// ── Netflix: echte Top-10-Daten direkt von der Tudum-Seite (HTML, klein) ────── +// Anders als die riesige XLSX-Datei (5 Jahre, alle Länder) ist diese Seite pro +// Abruf nur eine einzelne, kleine, server-gerenderte Seite – die enthält die +// "Ranking"-Tabelle bereits fertig im HTML, kein client-seitiges Nachladen, +// kein großer Download. Wird 24h gecacht (Netflix aktualisiert nur wöchentlich). +async function getNetflixTudumTop10(mediaKind /* 'movie' | 'tv' */) { + const cacheKey = `netflix-tudum:${mediaKind}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + + const path = mediaKind === 'tv' ? '/germany/tv' : '/germany'; + const resp = await fetch(`https://www.netflix.com/tudum/top10${path}`, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DickenDock/1.0)' }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) throw new Error(`Tudum nicht erreichbar (HTTP ${resp.status})`); + const html = await resp.text(); + + // Die Ranking-Tabelle steht als Markdown-ähnliche Tabelle im Seiten-Markup: + // "01![...]Titelname: Season X | 2" – wir extrahieren Rang + Titel daraus. + // Robusterer Weg: die Zeilen direkt nach "## Top 10 ... overview" parsen. + const rows = []; + const tableSection = html.split(/Top 10 (?:Shows|Movies) in Germany overview/i)[1] || ''; + const lineMatches = tableSection.matchAll(/\|\s*(\d{2})!\[[^\]]*\]\([^)]*\)([^|]+?)\s*\|\s*(\d+)\s*\|/g); + for (const m of lineMatches) { + const rank = parseInt(m[1], 10); + const title = m[2].trim(); + if (rank >= 1 && rank <= 10 && title) rows.push({ rank, title, weeksInTop10: parseInt(m[3], 10) || null }); + } + + // Fallback: falls das Tabellen-Format sich ändert, aus der Bild-Alt-Text-Liste lesen + if (rows.length < 5) { + const altMatches = html.matchAll(/!\[([^\]]+?)(?::\s*(?:Limited Series|Season \d+))?\]\([^)]*\)[\s\S]{0,80}?#(\d{1,2}) in (?:Shows|Movies)/g); + let i = 0; + for (const m of altMatches) { + i++; + const rank = parseInt(m[2], 10); + if (rank >= 1 && rank <= 10 && !rows.find(r => r.rank === rank)) { + rows.push({ rank, title: m[1].trim(), weeksInTop10: null }); + } + if (i > 30) break; // Sicherheitslimit + } + } + + rows.sort((a, b) => a.rank - b.rank); + const top10 = rows.slice(0, 10); + cacheSet(cacheKey, top10, 24 * 60 * 60 * 1000); // 24h Cache + return top10; +} + +// Netflix-Titel (nur Name, z.B. "Nemesis: Season 1") mit TMDb abgleichen +async function matchTudumTitleToTmdb(rawTitle, mediaKind, genreMap) { + // "Nemesis: Season 1" / "The Witness: Limited Series" → reiner Titel ohne Zusatz + const cleanTitle = rawTitle.replace(/:\s*(Season\s*\d+|Limited Series|Part\s*\d+).*$/i, '').trim(); + try { + const searchPath = mediaKind === 'tv' ? '/search/tv' : '/search/movie'; + const data = await tmdb(searchPath, { query: cleanTitle }); + const hit = (data.results || [])[0]; + if (!hit) return null; + if (mediaKind === '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' }, @@ -538,6 +614,45 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { const cachedResult = cacheGet(streamCacheKey); if (cachedResult !== undefined) return res.json(cachedResult); + // ── NETFLIX: echte Tudum-Charts, leichtgewichtiges HTML, kein großer Download ── + if (provider.id === 'netflix') { + try { + const genreMap = await getGenreMap(); + const [tudumMovies, tudumShows] = await Promise.all([ + getNetflixTudumTop10('movie'), + getNetflixTudumTop10('tv'), + ]); + + if (tudumMovies.length >= 5 && tudumShows.length >= 5) { + const movies = (await Promise.all( + tudumMovies.map(async t => { + const m = await matchTudumTitleToTmdb(t.title, 'movie', genreMap); + return m ? { ...m, rank: t.rank, weeksInTop10: t.weeksInTop10, tudumTitle: t.title } : null; + }) + )).filter(Boolean); + const tvShows = (await Promise.all( + tudumShows.map(async t => { + const m = await matchTudumTitleToTmdb(t.title, 'tv', genreMap); + return m ? { ...m, rank: t.rank, weeksInTop10: t.weeksInTop10, tudumTitle: t.title } : null; + }) + )).filter(Boolean); + + const result = { + provider: { id: provider.id, label: provider.label }, + movies, tvShows, + isOfficialData: true, + filterInfo: null, + }; + cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); + return res.json(result); + } + // Zu wenig geparst → unten zur TMDb-Näherung durchfallen lassen + } catch (tudumErr) { + // Tudum nicht erreichbar/Format geändert → unten zur TMDb-Näherung durchfallen lassen + } + } + // ── ENDE NETFLIX-Sonderfall (bei Erfolg wurde oben bereits geantwortet) ────── + // Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1) const minYear = new Date().getFullYear() - 1; const minDate = `${minYear}-01-01`; @@ -677,6 +792,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => { const result = { provider: { id: provider.id, label: provider.label }, movies, tvShows, + isOfficialData: false, filterInfo: { minYear, maxOtherLanguageTotal: MAX_OTHER_LANGUAGE_TOTAL }, }; cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index a69facb..1d6186a 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 (
-
- Top 10 Filme & Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität (Näherung, keine offiziellen Anbieter-Daten)
- Deutsch & Englisch unbegrenzt · alle anderen Sprachen zusammen max. 2 Plätze in den Top 10 -
+ {data?.isOfficialData ? ( +
+ 📊 Original Netflix Top 10 (DE) – echte, offizielle Charts direkt von Netflix (netflix.com/tudum) +
+ ) : ( +
+ Top 10 Filme & Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität (Näherung, keine offiziellen Anbieter-Daten)
+ Deutsch & Englisch unbegrenzt · alle anderen Sprachen zusammen max. 2 Plätze in den Top 10 +
+ )} {/* Anbieter-Auswahl */}
{providers.map(p => (