feat: Netflix Top10 mit echten offiziellen Daten, Sprachgewichtung DE/EN bevorzugt für restliche Anbieter
This commit is contained in:
@@ -8,6 +8,6 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"puppeteer-core": "^22.0.0"
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,13 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||||
|
const XLSX = require('xlsx');
|
||||||
|
|
||||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||||
const XREL_BASE = 'https://api.xrel.to/v2';
|
const XREL_BASE = 'https://api.xrel.to/v2';
|
||||||
const THIS_YEAR = new Date().getFullYear();
|
const THIS_YEAR = new Date().getFullYear();
|
||||||
const MIN_YEAR = THIS_YEAR - 1;
|
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
|
// Sprachen die wir NICHT wollen
|
||||||
const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']);
|
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 ──────────────────────────────────────────────
|
// ── Streaming Top 10 pro Anbieter ──────────────────────────────────────────────
|
||||||
// TMDb Watch-Provider-IDs für Deutschland (watch_region=DE)
|
// 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 = [
|
const STREAMING_PROVIDERS = [
|
||||||
{ id: 'netflix', label: 'Netflix', tmdbId: '8' },
|
{ id: 'netflix', label: 'Netflix', tmdbId: '8' },
|
||||||
{ id: 'disney', label: 'Disney+', tmdbId: '337' },
|
{ 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);
|
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' });
|
||||||
|
|
||||||
|
// ── 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)
|
// 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
|
// 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;
|
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
|
// Sprachgewichtung statt harter Quote: Deutsch & Englisch sind unbegrenzt
|
||||||
// nicht komplett dominiert – ECHTE Hits (z.B. Squid Game) rutschen wegen hoher vote_count
|
// erlaubt (das schaut hierzulande eh fast jeder). Für alle anderen Sprachen
|
||||||
// ohnehin weit nach oben und werden davon nicht ausgebremst.
|
// gilt ein Deckel von 2 Plätzen in den Top 10 – AUSSER ein Titel hat eine
|
||||||
const MAX_PER_LANGUAGE = 3;
|
// 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 = {
|
const movieParams = {
|
||||||
watch_region: 'DE',
|
watch_region: 'DE',
|
||||||
@@ -558,7 +657,7 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
|||||||
'first_air_date.gte': minDate,
|
'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([
|
const [movieP1, movieP2, movieP3, tvP1, tvP2, tvP3, genreMap] = await Promise.all([
|
||||||
tmdb('/discover/movie', { ...movieParams, page: 1 }),
|
tmdb('/discover/movie', { ...movieParams, page: 1 }),
|
||||||
tmdb('/discover/movie', { ...movieParams, page: 2 }),
|
tmdb('/discover/movie', { ...movieParams, page: 2 }),
|
||||||
@@ -569,26 +668,27 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
|||||||
getGenreMap(),
|
getGenreMap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Diversitäts-Deckelung anwenden: Liste bleibt nach Popularität sortiert,
|
// Liste bleibt nach Popularität sortiert. Deutsch/Englisch immer erlaubt.
|
||||||
// aber sobald eine Sprache MAX_PER_LANGUAGE erreicht hat, werden weitere
|
// Andere Sprachen: max. 2 Plätze, außer der Titel ist ein nachweislicher
|
||||||
// Titel dieser Sprache übersprungen (sie können trotzdem auftauchen sobald
|
// Crossover-Hit (sehr hohe vote_count) – der zählt dann nicht gegen den Deckel.
|
||||||
// ein Slot durch erschöpfte andere Sprachen frei bleibt – siehe Fallback unten).
|
function applyLanguageWeighting(rawList) {
|
||||||
function applyDiversityCap(rawList) {
|
const otherLangCount = {};
|
||||||
const langCount = {};
|
|
||||||
const picked = [];
|
const picked = [];
|
||||||
const overflow = [];
|
const overflow = [];
|
||||||
for (const item of rawList) {
|
for (const item of rawList) {
|
||||||
const lang = item.original_language || 'xx';
|
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);
|
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 {
|
} else {
|
||||||
overflow.push(item); // falls 10 nicht erreicht werden, später auffüllen
|
overflow.push(item);
|
||||||
}
|
}
|
||||||
if (picked.length >= 10) break;
|
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) {
|
for (const item of overflow) {
|
||||||
if (picked.length >= 10) break;
|
if (picked.length >= 10) break;
|
||||||
picked.push(item);
|
picked.push(item);
|
||||||
@@ -601,10 +701,11 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
|||||||
const tvRaw = [...(tvP1.results||[]), ...(tvP2.results||[]), ...(tvP3.results||[])]
|
const tvRaw = [...(tvP1.results||[]), ...(tvP2.results||[]), ...(tvP3.results||[])]
|
||||||
.filter(m => !BLOCKED_LANGS.has(m.original_language));
|
.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' }));
|
.map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' }));
|
||||||
|
|
||||||
const tvShows = applyDiversityCap(tvRaw)
|
const tvShows = applyLanguageWeighting(tvRaw)
|
||||||
.map((m, i) => ({
|
.map((m, i) => ({
|
||||||
id: m.id, media_type: 'tv', rank: i + 1,
|
id: m.id, media_type: 'tv', rank: i + 1,
|
||||||
title: m.name || m.original_name,
|
title: m.name || m.original_name,
|
||||||
@@ -620,10 +721,11 @@ router.get('/streaming/:providerId', authenticate, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
provider: { id: provider.id, label: provider.label },
|
provider: { id: provider.id, label: provider.label },
|
||||||
movies, tvShows,
|
movies, tvShows,
|
||||||
|
isOfficialData: false,
|
||||||
filterInfo: {
|
filterInfo: {
|
||||||
minYear,
|
minYear,
|
||||||
minVotes: MIN_VOTES,
|
minVotes: MIN_VOTES,
|
||||||
maxPerLanguage: MAX_PER_LANGUAGE,
|
maxPerOtherLanguage: MAX_PER_OTHER_LANGUAGE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|||||||
@@ -476,10 +476,16 @@ function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}>
|
{data?.isOfficialData ? (
|
||||||
Top 10 Filme & Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität<br/>
|
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}>
|
||||||
Nur wirklich bekannte Titel (Mindestanzahl Bewertungen) · max. 3 Titel je Sprache, damit eine Region die Liste nicht überflutet – echte Hits wie z.B. „Squid Game" rutschen trotzdem ganz nach oben
|
📊 Original Netflix Top 10 (DE) – echte, offizielle Daten direkt von Netflix, keine Filterung
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12,lineHeight:1.6}}>
|
||||||
|
Top 10 Filme & Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität (Näherung, keine offiziellen Anbieter-Daten)<br/>
|
||||||
|
Nur wirklich bekannte Titel · Deutsch & Englisch unbegrenzt, andere Sprachen max. 2 Plätze – echte Hits wie „Squid Game" rutschen trotzdem rein
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Anbieter-Auswahl */}
|
{/* Anbieter-Auswahl */}
|
||||||
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
|
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
|
||||||
{providers.map(p => (
|
{providers.map(p => (
|
||||||
|
|||||||
Reference in New Issue
Block a user