mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1968 lines
97 KiB
JavaScript
1968 lines
97 KiB
JavaScript
/**
|
||
* Movix Wrapped 2026 - Data Collection Routes
|
||
* Collects viewing data and page visits for the annual Wrapped summary
|
||
*
|
||
* Performance notes:
|
||
* - Redis is used for generated-wrapped cache AND TMDB detail cache
|
||
* - All independent SQL queries run in Promise.all (parallel)
|
||
* - TMDB enrichment uses Redis-backed batch lookup
|
||
*/
|
||
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const jwt = require('jsonwebtoken');
|
||
const { fetchTmdbDetails } = require('./utils/tmdbCache');
|
||
|
||
// Lazy import pour éviter les dépendances circulaires au démarrage
|
||
let _syncModule = null;
|
||
function getSyncModule() {
|
||
if (!_syncModule) _syncModule = require('./routes/sync');
|
||
return _syncModule;
|
||
}
|
||
|
||
// TMDB API Configuration
|
||
const TMDB_API_URL = 'https://api.themoviedb.org/3';
|
||
|
||
// Redis key prefixes & TTLs
|
||
const WRAPPED_CACHE_PREFIX = 'wrapped:gen:'; // generated wrapped result
|
||
const PERCENTILE_CACHE_PREFIX = 'wrapped:pctile:'; // percentile ranking (global, heavy query)
|
||
const WRAPPED_CACHE_TTL = 10 * 60; // 10 min (seconds)
|
||
const PERCENTILE_CACHE_TTL = 30 * 60; // 30 min (seconds) - global data, doesn't change fast
|
||
|
||
// Same secret as server.js
|
||
const JWT_SECRET = process.env.JWT_SECRET;
|
||
|
||
// Set by server.js via initWrappedRoutes()
|
||
let pool = null;
|
||
let redis = null;
|
||
|
||
/**
|
||
* Initialize the router with MySQL pool and Redis client
|
||
* @param {object} mysqlPool - MySQL connection pool
|
||
* @param {object} redisClient - ioredis instance (optional but recommended)
|
||
*/
|
||
function initWrappedRoutes(mysqlPool, redisClient) {
|
||
pool = mysqlPool;
|
||
redis = redisClient || null;
|
||
}
|
||
|
||
// ─── Helpers: Redis-safe get / set ───────────────────────────────────────────
|
||
function redisReady() {
|
||
return redis && redis.status === 'ready';
|
||
}
|
||
|
||
async function redisGet(key) {
|
||
if (!redisReady()) return null;
|
||
try { return await redis.get(key); } catch { return null; }
|
||
}
|
||
|
||
async function redisSet(key, value, ttlSeconds) {
|
||
if (!redisReady()) return;
|
||
try { await redis.set(key, value, 'EX', ttlSeconds); } catch { /* ignore */ }
|
||
}
|
||
|
||
// ─── Rate limiting par profil (Redis-backed) ────────────────────────────────
|
||
const RATE_LIMIT_PREFIX = 'wrapped:rl:';
|
||
|
||
/**
|
||
* Crée un middleware de rate limiting par profileId (fallback userId).
|
||
* @param {string} endpoint - Nom de l'endpoint (pour la clé Redis)
|
||
* @param {number} maxRequests - Nombre max de requêtes dans la fenêtre
|
||
* @param {number} windowSeconds - Durée de la fenêtre en secondes
|
||
* @param {function} extractId - Fonction (req) => identifiant pour le rate limit
|
||
*/
|
||
function rateLimitPerProfile(endpoint, maxRequests, windowSeconds, extractId) {
|
||
return async (req, res, next) => {
|
||
if (!redisReady()) return next(); // pas de Redis = pas de rate limit
|
||
|
||
const id = extractId(req);
|
||
if (!id) return next(); // pas d'identifiant = skip
|
||
|
||
const key = `${RATE_LIMIT_PREFIX}${endpoint}:${id}`;
|
||
try {
|
||
const current = await redis.incr(key);
|
||
if (current === 1) {
|
||
await redis.expire(key, windowSeconds);
|
||
}
|
||
// Headers informatifs
|
||
res.set('X-RateLimit-Limit', String(maxRequests));
|
||
res.set('X-RateLimit-Remaining', String(Math.max(0, maxRequests - current)));
|
||
|
||
if (current > maxRequests) {
|
||
const ttl = await redis.ttl(key);
|
||
res.set('Retry-After', String(ttl > 0 ? ttl : windowSeconds));
|
||
return res.status(429).json({
|
||
success: false,
|
||
error: 'Trop de requêtes. Réessaie dans quelques instants.'
|
||
});
|
||
}
|
||
next();
|
||
} catch {
|
||
next(); // en cas d'erreur Redis, on laisse passer
|
||
}
|
||
};
|
||
}
|
||
|
||
// /track : le frontend envoie ~2 req/30s en usage normal, avec les changements
|
||
// de visibilité/contenu/épisode ça peut monter. 30 req/min est très confortable.
|
||
const trackRateLimit = rateLimitPerProfile('track', 30, 60, (req) => {
|
||
return req.body?.profileId || req.user?.sub;
|
||
});
|
||
|
||
// /generate : requête lourde (SQL + TMDB). 6 req/min suffit largement
|
||
// (un refresh + quelques changements d'année).
|
||
const generateRateLimit = rateLimitPerProfile('generate', 6, 60, (req) => {
|
||
return req.headers['x-profile-id'] || req.query.profileId || req.user?.sub;
|
||
});
|
||
|
||
const WRAPPED_UNLOCK_REQUIREMENTS = Object.freeze({
|
||
minutes: 120,
|
||
uniqueTitles: 3,
|
||
sessions: 5,
|
||
activeDays: 2
|
||
});
|
||
|
||
function buildWrappedProgress({ totalMinutes, uniqueTitles, totalSessions, totalActiveDays }) {
|
||
const current = {
|
||
minutes: Math.max(0, totalMinutes || 0),
|
||
uniqueTitles: Math.max(0, uniqueTitles || 0),
|
||
sessions: Math.max(0, totalSessions || 0),
|
||
activeDays: Math.max(0, totalActiveDays || 0)
|
||
};
|
||
|
||
const missing = {
|
||
minutes: Math.max(0, WRAPPED_UNLOCK_REQUIREMENTS.minutes - current.minutes),
|
||
uniqueTitles: Math.max(0, WRAPPED_UNLOCK_REQUIREMENTS.uniqueTitles - current.uniqueTitles),
|
||
sessions: Math.max(0, WRAPPED_UNLOCK_REQUIREMENTS.sessions - current.sessions),
|
||
activeDays: Math.max(0, WRAPPED_UNLOCK_REQUIREMENTS.activeDays - current.activeDays)
|
||
};
|
||
|
||
const progressRatios = [
|
||
Math.min(current.minutes / WRAPPED_UNLOCK_REQUIREMENTS.minutes, 1),
|
||
Math.min(current.uniqueTitles / WRAPPED_UNLOCK_REQUIREMENTS.uniqueTitles, 1),
|
||
Math.min(current.sessions / WRAPPED_UNLOCK_REQUIREMENTS.sessions, 1),
|
||
Math.min(current.activeDays / WRAPPED_UNLOCK_REQUIREMENTS.activeDays, 1)
|
||
];
|
||
|
||
return {
|
||
isEligible: Object.values(missing).every((value) => value === 0),
|
||
completionPercent: Math.round((progressRatios.reduce((sum, value) => sum + value, 0) / progressRatios.length) * 100),
|
||
missingCriteriaCount: Object.values(missing).filter((value) => value > 0).length,
|
||
requirements: { ...WRAPPED_UNLOCK_REQUIREMENTS },
|
||
current,
|
||
missing
|
||
};
|
||
}
|
||
|
||
// ─── TMDB helpers (via tmdbCache centralisé) ────────────────────────────────
|
||
|
||
/**
|
||
* Extrait les champs nécessaires à Wrapped depuis une réponse TMDB complète.
|
||
*/
|
||
function extractWrappedFields(data) {
|
||
if (!data) return null;
|
||
const releaseDate = data.release_date || data.first_air_date || null;
|
||
return {
|
||
title: data.title || data.name,
|
||
poster_path: data.poster_path,
|
||
backdrop_path: data.backdrop_path || null,
|
||
genres: (data.genres || []).map(g => typeof g === 'string' ? g : g.name),
|
||
vote_average: data.vote_average || null,
|
||
runtime: data.runtime || data.episode_run_time?.[0] || null,
|
||
release_year: releaseDate ? parseInt(String(releaseDate).slice(0, 4)) || null : null
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fetch details from TMDB (via tmdbCache Redis centralisé)
|
||
*/
|
||
async function fetchTMDBDetails(contentId, contentType) {
|
||
// Skip live-tv as it's not from TMDB
|
||
if (contentType === 'live-tv') {
|
||
return { title: `Live TV #${contentId}`, poster_path: null, genres: [] };
|
||
}
|
||
|
||
const mediaType = contentType === 'anime' ? 'tv' : contentType;
|
||
const data = await fetchTmdbDetails(TMDB_API_URL, process.env.TMDB_API_KEY, contentId, mediaType, 'fr-FR');
|
||
return extractWrappedFields(data);
|
||
}
|
||
|
||
/**
|
||
* Enrich content array with TMDB data (parallel, Redis-cached)
|
||
*/
|
||
async function enrichWithTMDBData(contents) {
|
||
return Promise.all(contents.map(async (content) => {
|
||
if (content.content_title && content.poster_path) {
|
||
return content; // Already has data
|
||
}
|
||
const details = await fetchTMDBDetails(content.content_id, content.content_type);
|
||
return {
|
||
...content,
|
||
content_title: details?.title || content.content_title || `${content.content_type} #${content.content_id}`,
|
||
poster_path: details?.poster_path || content.poster_path || null,
|
||
genres: details?.genres || [],
|
||
vote_average: details?.vote_average || null
|
||
};
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Middleware to verify JWT token
|
||
*/
|
||
const verifyToken = (req, res, next) => {
|
||
const authHeader = req.headers['authorization'];
|
||
const token = authHeader && authHeader.split(' ')[1];
|
||
|
||
if (!token) {
|
||
// If no token, check if we can proceed without it (e.g. for simple tracking?)
|
||
// But user asked for verification "like other routes", so we enforce it.
|
||
// However, wrapped tracker might not have header set if useWrappedTracker uses standard fetch without setting headers.
|
||
// Let's check useWrappedTracker.ts... It uses fetch without custom headers?
|
||
// Wait, I need to check useWrappedTracker.ts.
|
||
// If useWrappedTracker doesn't send Authorization header, this will break tracking.
|
||
return res.status(401).json({ success: false, error: 'Access denied. No token provided.' });
|
||
}
|
||
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||
req.user = decoded;
|
||
next();
|
||
} catch (error) {
|
||
return res.status(403).json({ success: false, error: 'Invalid token.' });
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Create required tables if they don't exist
|
||
*/
|
||
async function initTables() {
|
||
if (!pool) {
|
||
console.error('[Wrapped] Cannot init tables - no database pool');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Create wrapped_viewing_data table
|
||
await pool.execute(`
|
||
CREATE TABLE IF NOT EXISTS wrapped_viewing_data (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
profile_id VARCHAR(255),
|
||
content_type ENUM('movie', 'tv', 'anime', 'live-tv') NOT NULL,
|
||
content_id VARCHAR(255) NOT NULL,
|
||
content_title VARCHAR(255),
|
||
season_number INT DEFAULT NULL,
|
||
episode_number INT DEFAULT NULL,
|
||
watch_duration INT NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
hour_of_day TINYINT DEFAULT NULL,
|
||
month INT NOT NULL,
|
||
year INT NOT NULL,
|
||
INDEX idx_user_year (user_id, year),
|
||
INDEX idx_content (content_type, content_id),
|
||
INDEX idx_hour (user_id, year, hour_of_day)
|
||
)
|
||
`);
|
||
|
||
// Create wrapped_pages_data table
|
||
await pool.execute(`
|
||
CREATE TABLE IF NOT EXISTS wrapped_pages_data (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
profile_id VARCHAR(255),
|
||
page_name VARCHAR(100) NOT NULL,
|
||
duration INT DEFAULT 0,
|
||
meta_data JSON,
|
||
month INT NOT NULL,
|
||
year INT NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_page_stats (user_id, page_name, year)
|
||
)
|
||
`);
|
||
|
||
// Migration: add hour_of_day column if it doesn't exist (for tables created before this column was added)
|
||
try {
|
||
await pool.execute(`
|
||
ALTER TABLE wrapped_viewing_data ADD COLUMN hour_of_day TINYINT DEFAULT NULL AFTER created_at
|
||
`);
|
||
console.log('[Wrapped] Migration: added hour_of_day column');
|
||
} catch (alterErr) {
|
||
// ER_DUP_FIELDNAME (1060) means column already exists — that's fine
|
||
if (alterErr.errno !== 1060) {
|
||
console.warn('[Wrapped] Migration warning (hour_of_day):', alterErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: add idx_hour index if it doesn't exist
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_hour ON wrapped_viewing_data (user_id, year, hour_of_day)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_hour index');
|
||
} catch (indexErr) {
|
||
// ER_DUP_KEYNAME (1061) means index already exists — that's fine
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_hour):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: add composite index for the track upsert SELECT (covers all lookup columns)
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_viewing_lookup ON wrapped_viewing_data
|
||
(user_id, profile_id, content_type, content_id, month, year, hour_of_day)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_viewing_lookup index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_viewing_lookup):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: add composite index for page data upsert SELECT
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_page_lookup ON wrapped_pages_data
|
||
(user_id, profile_id, page_name, month, year)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_page_lookup index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_page_lookup):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: index on (year) alone for the global percentile query
|
||
try {
|
||
await pool.execute(`CREATE INDEX idx_year ON wrapped_viewing_data (year)`);
|
||
console.log('[Wrapped] Migration: added idx_year index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_year):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: covering index for generate queries (user_id, year, watch_duration, content_id, content_type)
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_generate_cover ON wrapped_viewing_data
|
||
(user_id, year, profile_id, content_type, content_id, watch_duration)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_generate_cover index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_generate_cover):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: index for first/last watch ORDER BY created_at
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_user_year_created ON wrapped_viewing_data (user_id, year, created_at)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_user_year_created index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_user_year_created):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: index for pages generate (user_id, year, page_name, duration)
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_page_generate ON wrapped_pages_data (user_id, year, profile_id, page_name, duration)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_page_generate index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_page_generate):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
// Migration: covering index for the global percentile GROUP BY query
|
||
// Allows MySQL to compute SUM(watch_duration) GROUP BY user_id entirely from the index
|
||
try {
|
||
await pool.execute(`
|
||
CREATE INDEX idx_percentile_cover ON wrapped_viewing_data (year, user_id, watch_duration)
|
||
`);
|
||
console.log('[Wrapped] Migration: added idx_percentile_cover index');
|
||
} catch (indexErr) {
|
||
if (indexErr.errno !== 1061) {
|
||
console.warn('[Wrapped] Migration warning (idx_percentile_cover):', indexErr.message);
|
||
}
|
||
}
|
||
|
||
console.log('[Wrapped] Tables initialized successfully');
|
||
} catch (error) {
|
||
console.error('[Wrapped] Error initializing tables:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* POST /api/wrapped/track
|
||
* Batch endpoint to receive viewing/page data
|
||
*/
|
||
router.post('/track', verifyToken, trackRateLimit, async (req, res) => {
|
||
try {
|
||
if (!pool) {
|
||
return res.status(503).json({ success: false, error: 'Database not available' });
|
||
}
|
||
|
||
const {
|
||
userId,
|
||
profileId,
|
||
type,
|
||
month,
|
||
year,
|
||
duration,
|
||
// Viewing-specific fields
|
||
contentType,
|
||
contentId,
|
||
contentTitle,
|
||
seasonNumber,
|
||
episodeNumber,
|
||
hourOfDay,
|
||
// Page-specific fields
|
||
pageName,
|
||
meta,
|
||
} = req.body;
|
||
|
||
// Security: userId du body doit correspondre au JWT
|
||
if (!req.user || !req.user.sub) {
|
||
return res.status(401).json({ success: false, error: 'Token invalide' });
|
||
}
|
||
if (userId !== req.user.sub) {
|
||
console.warn(`[Wrapped] User ID mismatch blocked: Body ${userId} vs Token ${req.user.sub}`);
|
||
return res.status(403).json({ success: false, error: 'User ID mismatch' });
|
||
}
|
||
|
||
// Security: vérifier que le profileId appartient bien à cet utilisateur
|
||
if (profileId) {
|
||
try {
|
||
const userType = req.user.userType || 'oauth';
|
||
const { readUserData } = getSyncModule();
|
||
const userData = await readUserData(userType, req.user.sub);
|
||
const profiles = userData?.profiles || [];
|
||
if (!profiles.some(p => p.id === profileId)) {
|
||
console.warn(`[Wrapped] Profile ownership denied: ${profileId} not owned by ${req.user.sub}`);
|
||
return res.status(403).json({ success: false, error: 'Profile does not belong to this user' });
|
||
}
|
||
} catch (err) {
|
||
console.error(`[Wrapped] Error checking profile ownership:`, err.message);
|
||
return res.status(500).json({ success: false, error: 'Could not verify profile ownership' });
|
||
}
|
||
}
|
||
|
||
// Validate required fields
|
||
if (!userId || !type || !month || !year || duration === undefined) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Missing required fields: userId, type, month, year, duration'
|
||
});
|
||
}
|
||
|
||
// Validate numeric ranges to prevent data pollution
|
||
const parsedMonth = parseInt(month);
|
||
const parsedYear = parseInt(year);
|
||
const parsedDuration = parseInt(duration);
|
||
const currentYear = new Date().getFullYear();
|
||
|
||
if (isNaN(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
|
||
return res.status(400).json({ success: false, error: 'Invalid month (1-12)' });
|
||
}
|
||
if (isNaN(parsedYear) || parsedYear < 2024 || parsedYear > currentYear) {
|
||
return res.status(400).json({ success: false, error: `Invalid year (2024-${currentYear})` });
|
||
}
|
||
if (isNaN(parsedDuration) || parsedDuration < 0 || parsedDuration > 60) {
|
||
return res.status(400).json({ success: false, error: 'Invalid duration (0-60 seconds)' });
|
||
}
|
||
if (!['viewing', 'page'].includes(type)) {
|
||
return res.status(400).json({ success: false, error: 'Invalid type. Must be "viewing" or "page"' });
|
||
}
|
||
|
||
if (type === 'viewing') {
|
||
// Validate viewing-specific fields
|
||
if (!contentType || !contentId) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Missing required fields for viewing: contentType, contentId'
|
||
});
|
||
}
|
||
|
||
// Resolve hour: prefer client-sent hourOfDay, fallback to server hour
|
||
const resolvedHour = hourOfDay !== undefined && hourOfDay !== null ? parseInt(hourOfDay) : new Date().getHours();
|
||
|
||
// Try to update existing record for the same content in the same month AND same hour
|
||
// Splitting by hour ensures accurate listening clock data
|
||
const [existingRows] = await pool.execute(
|
||
`SELECT id, watch_duration FROM wrapped_viewing_data
|
||
WHERE user_id = ? AND profile_id <=> ? AND content_type = ? AND content_id = ?
|
||
AND month = ? AND year = ? AND hour_of_day <=> ?
|
||
AND season_number <=> ? AND episode_number <=> ?
|
||
LIMIT 1`,
|
||
[userId, profileId || null, contentType, contentId, month, year,
|
||
resolvedHour, seasonNumber || null, episodeNumber || null]
|
||
);
|
||
|
||
if (existingRows.length > 0) {
|
||
// Update existing record
|
||
const newDuration = existingRows[0].watch_duration + Math.floor(duration);
|
||
await pool.execute(
|
||
`UPDATE wrapped_viewing_data SET watch_duration = ? WHERE id = ?`,
|
||
[newDuration, existingRows[0].id]
|
||
);
|
||
} else {
|
||
// Insert new record
|
||
await pool.execute(
|
||
`INSERT INTO wrapped_viewing_data
|
||
(user_id, profile_id, content_type, content_id, content_title, season_number, episode_number, watch_duration, hour_of_day, month, year)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[userId, profileId || null, contentType, contentId, contentTitle || null,
|
||
seasonNumber || null, episodeNumber || null, Math.floor(duration),
|
||
resolvedHour, month, year]
|
||
);
|
||
}
|
||
} else if (type === 'page') {
|
||
// Validate page-specific fields
|
||
if (!pageName) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Missing required field for page: pageName'
|
||
});
|
||
}
|
||
|
||
// Try to update existing record for the same page in the same month
|
||
const [existingRows] = await pool.execute(
|
||
`SELECT id, duration FROM wrapped_pages_data
|
||
WHERE user_id = ? AND profile_id <=> ? AND page_name = ? AND month = ? AND year = ?
|
||
LIMIT 1`,
|
||
[userId, profileId || null, pageName, month, year]
|
||
);
|
||
|
||
if (existingRows.length > 0) {
|
||
// Update existing record
|
||
const newDuration = existingRows[0].duration + Math.floor(duration);
|
||
await pool.execute(
|
||
`UPDATE wrapped_pages_data SET duration = ? WHERE id = ?`,
|
||
[newDuration, existingRows[0].id]
|
||
);
|
||
} else {
|
||
// Insert new record
|
||
await pool.execute(
|
||
`INSERT INTO wrapped_pages_data
|
||
(user_id, profile_id, page_name, duration, meta_data, month, year)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
[userId, profileId || null, pageName, Math.floor(duration),
|
||
meta ? JSON.stringify(meta) : null, month, year]
|
||
);
|
||
}
|
||
} else {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Invalid type. Must be "viewing" or "page"'
|
||
});
|
||
}
|
||
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('[Wrapped] Error tracking data:', error);
|
||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Generate Wrapped summary using templates (like Spotify - no AI)
|
||
* Header 'x-profile-id': Optional profile ID to filter stats
|
||
*
|
||
* PERFORMANCE: All independent SQL queries run in Promise.all,
|
||
* TMDB results are cached in Redis (24 h), generated wrapped in Redis (10 min).
|
||
*/
|
||
router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) => {
|
||
const _t = { start: Date.now() };
|
||
const { year } = req.params;
|
||
const userId = req.user.sub;
|
||
const profileId = req.headers['x-profile-id'] || req.query.profileId;
|
||
|
||
console.log(`[Wrapped][PERF] ── Generate ${year} for user=${userId} profile=${profileId || 'all'} ──`);
|
||
|
||
// Validate year parameter
|
||
const parsedYear = parseInt(year);
|
||
if (isNaN(parsedYear) || parsedYear < 2024 || parsedYear > new Date().getFullYear()) {
|
||
return res.status(400).json({ success: false, error: 'Invalid year' });
|
||
}
|
||
|
||
if (!pool) {
|
||
return res.status(500).json({ success: false, error: 'Database not initialized' });
|
||
}
|
||
|
||
// ── 0. Redis cache check (skip if ?fresh=1) ────────────────────────────────
|
||
const cacheKey = `${WRAPPED_CACHE_PREFIX}${userId}-${profileId || 'all'}-${year}`;
|
||
if (!req.query.fresh) {
|
||
const cached = await redisGet(cacheKey);
|
||
if (cached) {
|
||
try {
|
||
console.log(`[Wrapped][PERF] ⚡ Redis cache HIT — total ${Date.now() - _t.start}ms`);
|
||
return res.json(JSON.parse(cached));
|
||
} catch { /* regenerate */ }
|
||
}
|
||
console.log(`[Wrapped][PERF] Redis cache MISS (${Date.now() - _t.start}ms)`);
|
||
} else {
|
||
console.log(`[Wrapped][PERF] Cache skipped (?fresh=1)`);
|
||
}
|
||
|
||
try {
|
||
// Base query conditions
|
||
let whereClause = 'WHERE user_id = ? AND year = ?';
|
||
let queryParams = [userId, year];
|
||
if (profileId) {
|
||
whereClause += ' AND profile_id = ?';
|
||
queryParams.push(profileId);
|
||
}
|
||
|
||
// ── Helper: timed query ────────────────────────────────────────────────
|
||
const _sqlTimings = {};
|
||
async function timedQuery(label, queryFn) {
|
||
const t0 = Date.now();
|
||
const result = await queryFn();
|
||
_sqlTimings[label] = Date.now() - t0;
|
||
return result;
|
||
}
|
||
|
||
// ── Helper: fetch percentile (Redis cache or SQL fallback) ──────────────
|
||
async function fetchPercentile() {
|
||
const pctCacheKey = `${PERCENTILE_CACHE_PREFIX}${year}`;
|
||
const cachedPct = await redisGet(pctCacheKey);
|
||
if (cachedPct) {
|
||
try {
|
||
const rankings = JSON.parse(cachedPct);
|
||
const totalUsers = rankings.length;
|
||
const userRank = rankings.findIndex(u => u.user_id === userId) + 1;
|
||
const pct = totalUsers > 1 ? Math.round(((totalUsers - userRank) / totalUsers) * 100) : 99;
|
||
_sqlTimings['percentile'] = 0;
|
||
console.log(`[Wrapped][PERF] 📊 Percentile: Redis HIT — rank ${userRank}/${totalUsers} = top ${100 - pct}%`);
|
||
return pct;
|
||
} catch { /* fallback to SQL */ }
|
||
}
|
||
// Cache froid : ne JAMAIS bloquer la réponse. On renvoie null et on
|
||
// réchauffe le cache en arrière-plan pour les prochains appels.
|
||
_sqlTimings['percentile'] = 0;
|
||
setImmediate(async () => {
|
||
try {
|
||
// Single-flight cluster-wide : un seul worker lance le scan
|
||
if (redisReady()) {
|
||
const lock = await redis.set(`${PERCENTILE_CACHE_PREFIX}${year}:lock`, '1', 'EX', 300, 'NX');
|
||
if (!lock) return;
|
||
}
|
||
const t0 = Date.now();
|
||
const [allUsersStats] = await pool.execute(`
|
||
SELECT user_id, ROUND(SUM(watch_duration) / 60) as total
|
||
FROM wrapped_viewing_data WHERE year = ?
|
||
GROUP BY user_id ORDER BY total DESC
|
||
`, [year]);
|
||
redisSet(pctCacheKey, JSON.stringify(allUsersStats), PERCENTILE_CACHE_TTL);
|
||
console.log(`[Wrapped][PERF] 📊 Percentile warmup (background): ${Date.now() - t0}ms (${allUsersStats.length} users)`);
|
||
} catch (e) {
|
||
console.warn('[Wrapped] Percentile warmup failed:', e.message);
|
||
}
|
||
});
|
||
return null;
|
||
}
|
||
|
||
// ── 1. Fire ALL queries in parallel (user-scoped + percentile) ──────────
|
||
_t.sqlStart = Date.now();
|
||
const [
|
||
[viewingStats],
|
||
[typeStats],
|
||
[topContentAll],
|
||
[monthlyStats],
|
||
[topPages],
|
||
[hourlyStats],
|
||
[dailyActivity],
|
||
[firstWatch],
|
||
[lastWatch],
|
||
[recordDayRows],
|
||
[weekdayRows],
|
||
[rewatchRows],
|
||
percentile
|
||
] = await Promise.all([
|
||
// 1. Viewing Stats
|
||
timedQuery('viewingStats', () => pool.execute(`
|
||
SELECT
|
||
ROUND(SUM(watch_duration) / 60) as total_duration,
|
||
COUNT(DISTINCT content_id) as unique_titles,
|
||
COUNT(*) as total_sessions
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
`, queryParams)),
|
||
|
||
// 2. Distribution by Type
|
||
timedQuery('typeStats', () => pool.execute(`
|
||
SELECT content_type, ROUND(SUM(watch_duration) / 60) as duration, COUNT(DISTINCT content_id) as count
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
GROUP BY content_type ORDER BY duration DESC
|
||
`, queryParams)),
|
||
|
||
// 3. Top Content (Top 10 — replaces old top5 + top10-genres queries)
|
||
timedQuery('topContent', () => pool.execute(`
|
||
SELECT
|
||
COALESCE(MAX(NULLIF(content_title, '')), MAX(content_title)) as content_title,
|
||
content_id,
|
||
content_type,
|
||
ROUND(SUM(watch_duration) / 60) as duration
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
AND content_type != 'live-tv'
|
||
GROUP BY content_id, content_type
|
||
ORDER BY duration DESC LIMIT 10
|
||
`, queryParams)),
|
||
|
||
// 4. Monthly breakdown
|
||
timedQuery('monthlyStats', () => pool.execute(`
|
||
SELECT month, ROUND(SUM(watch_duration) / 60) as duration
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
GROUP BY month ORDER BY duration DESC
|
||
`, queryParams)),
|
||
|
||
// 5. Top Pages
|
||
timedQuery('topPages', () => pool.execute(`
|
||
SELECT page_name, ROUND(SUM(duration) / 60) as duration
|
||
FROM wrapped_pages_data ${whereClause}
|
||
GROUP BY page_name ORDER BY duration DESC LIMIT 5
|
||
`, queryParams)),
|
||
|
||
// 6. Time-of-day distribution
|
||
timedQuery('hourlyStats', () => pool.execute(`
|
||
SELECT hour_of_day, ROUND(SUM(watch_duration) / 60) as duration, COUNT(*) as sessions
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
AND hour_of_day IS NOT NULL
|
||
GROUP BY hour_of_day ORDER BY hour_of_day
|
||
`, queryParams)),
|
||
|
||
// 7. Watching streaks (consecutive days)
|
||
timedQuery('dailyActivity', () => pool.execute(`
|
||
SELECT DISTINCT DATE(created_at) as watch_date
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
ORDER BY watch_date
|
||
`, queryParams)),
|
||
|
||
// 8a. First watch of the year
|
||
timedQuery('firstWatch', () => pool.execute(`
|
||
SELECT content_title, content_type, content_id, created_at
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
AND content_type != 'live-tv'
|
||
ORDER BY created_at ASC LIMIT 1
|
||
`, queryParams)),
|
||
|
||
// 8b. Last watch of the year
|
||
timedQuery('lastWatch', () => pool.execute(`
|
||
SELECT content_title, content_type, content_id, created_at
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
AND content_type != 'live-tv'
|
||
ORDER BY created_at DESC LIMIT 1
|
||
`, queryParams)),
|
||
|
||
// 9a. Jour record (journée avec le plus de visionnage)
|
||
timedQuery('recordDay', () => pool.execute(`
|
||
SELECT DATE(created_at) as day, ROUND(SUM(watch_duration) / 60) as minutes
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
GROUP BY DATE(created_at) ORDER BY minutes DESC LIMIT 1
|
||
`, queryParams)),
|
||
|
||
// 9b. Répartition par jour de semaine (DAYOFWEEK MySQL : 1=dimanche … 7=samedi)
|
||
timedQuery('weekdayStats', () => pool.execute(`
|
||
SELECT DAYOFWEEK(created_at) as dow, ROUND(SUM(watch_duration) / 60) as minutes
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
GROUP BY dow
|
||
`, queryParams)),
|
||
|
||
// 9c. Rewatch champion : film revu sur ≥3 jours distincts, ou même épisode revu sur ≥2 jours
|
||
timedQuery('rewatch', () => pool.execute(`
|
||
SELECT content_id, content_type, content_title, distinct_days FROM (
|
||
SELECT content_id, content_type,
|
||
COALESCE(MAX(NULLIF(content_title, '')), MAX(content_title)) as content_title,
|
||
COUNT(DISTINCT DATE(created_at)) as distinct_days
|
||
FROM wrapped_viewing_data ${whereClause} AND content_type = 'movie'
|
||
GROUP BY content_id, content_type
|
||
HAVING distinct_days >= 3
|
||
UNION ALL
|
||
SELECT content_id, content_type,
|
||
COALESCE(MAX(NULLIF(content_title, '')), MAX(content_title)) as content_title,
|
||
COUNT(DISTINCT DATE(created_at)) as distinct_days
|
||
FROM wrapped_viewing_data ${whereClause}
|
||
AND content_type IN ('tv', 'anime') AND season_number IS NOT NULL AND episode_number IS NOT NULL
|
||
GROUP BY content_id, content_type, season_number, episode_number
|
||
HAVING distinct_days >= 2
|
||
) rw ORDER BY distinct_days DESC LIMIT 1
|
||
`, [...queryParams, ...queryParams])),
|
||
|
||
// 10. Percentile (Redis-cached 30min, SQL fallback)
|
||
fetchPercentile()
|
||
]);
|
||
|
||
// Derive top5 and top10-for-genres from the single top10 result
|
||
const topContent = topContentAll.slice(0, 5);
|
||
const topContentForGenres = topContentAll; // all 10
|
||
|
||
_t.sqlEnd = Date.now();
|
||
const timingsStr = Object.entries(_sqlTimings).map(([k, v]) => `${k}=${v}ms`).join(' | ');
|
||
console.log(`[Wrapped][PERF] 🗄️ SQL+Percentile (10 parallel): ${_t.sqlEnd - _t.sqlStart}ms — ${timingsStr}`);
|
||
|
||
const totalMinutes = parseInt(viewingStats[0].total_duration) || 0;
|
||
const uniqueTitles = parseInt(viewingStats[0].unique_titles) || 0;
|
||
const totalSessions = parseInt(viewingStats[0].total_sessions) || 0;
|
||
const totalActiveDays = dailyActivity.length;
|
||
const progress = buildWrappedProgress({ totalMinutes, uniqueTitles, totalSessions, totalActiveDays });
|
||
|
||
if (!progress.isEligible) {
|
||
console.log(`[Wrapped][PERF] Not enough data yet — total ${Date.now() - _t.start}ms`);
|
||
return res.json({
|
||
success: true,
|
||
wrapped: null,
|
||
progress,
|
||
message: "Pas encore assez de données pour débloquer ce Wrapped."
|
||
});
|
||
}
|
||
|
||
// ── 2. TMDB enrichment (parallel, Redis-cached) ────────────────────────
|
||
_t.tmdbStart = Date.now();
|
||
|
||
// Deduplicate: merge top5 + top10-genres + first/last into a single unique set
|
||
const allContentToEnrich = new Map(); // key: "type:id" -> content row
|
||
[...topContent, ...topContentForGenres].forEach(c => {
|
||
const k = `${c.content_type}:${c.content_id}`;
|
||
if (!allContentToEnrich.has(k)) allContentToEnrich.set(k, c);
|
||
});
|
||
const firstWatchData = firstWatch[0] || null;
|
||
const lastWatchData = lastWatch[0] || null;
|
||
if (firstWatchData && !firstWatchData.content_title) {
|
||
const k = `${firstWatchData.content_type}:${firstWatchData.content_id}`;
|
||
if (!allContentToEnrich.has(k)) allContentToEnrich.set(k, firstWatchData);
|
||
}
|
||
if (lastWatchData && !lastWatchData.content_title) {
|
||
const k = `${lastWatchData.content_type}:${lastWatchData.content_id}`;
|
||
if (!allContentToEnrich.has(k)) allContentToEnrich.set(k, lastWatchData);
|
||
}
|
||
|
||
console.log(`[Wrapped][PERF] 🎯 TMDB: ${allContentToEnrich.size} unique items to enrich (deduped from ${topContent.length + topContentForGenres.length + (firstWatchData ? 1 : 0) + (lastWatchData ? 1 : 0)})`);
|
||
|
||
// ── Batch TMDB: single MGET for all Redis cache keys, then API-fetch misses ──
|
||
const tmdbCache = new Map(); // key -> details
|
||
const itemsNeedingFetch = []; // [{key, content}] — items not already complete
|
||
|
||
// Separate already-complete items from those needing TMDB lookup
|
||
for (const [key, content] of allContentToEnrich) {
|
||
if (content.content_title && content.poster_path) {
|
||
tmdbCache.set(key, { title: content.content_title, poster_path: content.poster_path, genres: content.genres || [], vote_average: content.vote_average || null });
|
||
} else {
|
||
itemsNeedingFetch.push({ key, content });
|
||
}
|
||
}
|
||
|
||
if (itemsNeedingFetch.length > 0 && redisReady()) {
|
||
// Build Redis keys for MGET (même pattern que tmdbCache.js)
|
||
const redisKeys = itemsNeedingFetch.map(({ content }) => {
|
||
const mediaType = content.content_type === 'anime' ? 'tv' : content.content_type;
|
||
return `tmdb:details:${mediaType}:${content.content_id}:fr-FR`;
|
||
});
|
||
|
||
try {
|
||
const tMget = Date.now();
|
||
const mgetResults = await redis.mget(...redisKeys);
|
||
console.log(`[Wrapped][TMDB] ⚡ MGET ${redisKeys.length} keys in ${Date.now() - tMget}ms`);
|
||
|
||
const apiMisses = []; // items not found in Redis
|
||
for (let i = 0; i < itemsNeedingFetch.length; i++) {
|
||
const { key, content } = itemsNeedingFetch[i];
|
||
if (mgetResults[i]) {
|
||
try {
|
||
tmdbCache.set(key, extractWrappedFields(JSON.parse(mgetResults[i])));
|
||
continue;
|
||
} catch { /* treat as miss */ }
|
||
}
|
||
apiMisses.push({ key, content });
|
||
}
|
||
|
||
// Fetch remaining misses from TMDB API in parallel
|
||
if (apiMisses.length > 0) {
|
||
console.log(`[Wrapped][TMDB] 🌐 Fetching ${apiMisses.length} items from API (${apiMisses.length} Redis misses)`);
|
||
await Promise.all(apiMisses.map(async ({ key, content }) => {
|
||
const details = await fetchTMDBDetails(content.content_id, content.content_type);
|
||
if (details) tmdbCache.set(key, details);
|
||
}));
|
||
}
|
||
} catch (mgetErr) {
|
||
// Fallback: parallel individual fetches
|
||
console.warn('[Wrapped][TMDB] MGET failed, falling back to individual fetches:', mgetErr.message);
|
||
await Promise.all(itemsNeedingFetch.map(async ({ key, content }) => {
|
||
const details = await fetchTMDBDetails(content.content_id, content.content_type);
|
||
if (details) tmdbCache.set(key, details);
|
||
}));
|
||
}
|
||
} else if (itemsNeedingFetch.length > 0) {
|
||
// No Redis: parallel individual fetches
|
||
await Promise.all(itemsNeedingFetch.map(async ({ key, content }) => {
|
||
const details = await fetchTMDBDetails(content.content_id, content.content_type);
|
||
if (details) tmdbCache.set(key, details);
|
||
}));
|
||
}
|
||
|
||
_t.tmdbEnd = Date.now();
|
||
console.log(`[Wrapped][PERF] 🎬 TMDB enrichment: ${_t.tmdbEnd - _t.tmdbStart}ms (${tmdbCache.size} resolved)`);
|
||
|
||
// Apply TMDB data back to arrays using the cache
|
||
function applyTMDB(arr) {
|
||
return arr.map(c => {
|
||
const k = `${c.content_type}:${c.content_id}`;
|
||
const d = tmdbCache.get(k);
|
||
if (!d) return { ...c, content_title: c.content_title || `${c.content_type} #${c.content_id}`, poster_path: c.poster_path || null, backdrop_path: null, genres: [], vote_average: null, release_year: null };
|
||
return { ...c, content_title: d.title || c.content_title, poster_path: d.poster_path || c.poster_path || null, backdrop_path: d.backdrop_path || null, genres: d.genres || [], vote_average: d.vote_average || null, release_year: d.release_year || null };
|
||
});
|
||
}
|
||
const enrichedTopContent = applyTMDB(topContent);
|
||
const enrichedForGenres = applyTMDB(topContentForGenres);
|
||
|
||
// Apply to first/last watch
|
||
if (firstWatchData && !firstWatchData.content_title) {
|
||
const d = tmdbCache.get(`${firstWatchData.content_type}:${firstWatchData.content_id}`);
|
||
if (d) firstWatchData.content_title = d.title;
|
||
}
|
||
if (lastWatchData && !lastWatchData.content_title) {
|
||
const d = tmdbCache.get(`${lastWatchData.content_type}:${lastWatchData.content_id}`);
|
||
if (d) lastWatchData.content_title = d.title;
|
||
}
|
||
|
||
// ── 3. Compute derived stats (pure JS, no I/O) ─────────────────────────
|
||
_t.computeStart = Date.now();
|
||
|
||
// Listening clock: peak hour
|
||
const hourlyMap = new Array(24).fill(0);
|
||
hourlyStats.forEach(h => { hourlyMap[h.hour_of_day] = parseInt(h.duration); });
|
||
const peakHour = hourlyMap.indexOf(Math.max(...hourlyMap));
|
||
const nightOwlMinutes = hourlyMap.slice(22, 24).reduce((a, b) => a + b, 0) + hourlyMap.slice(0, 5).reduce((a, b) => a + b, 0);
|
||
const earlyBirdMinutes = hourlyMap.slice(5, 10).reduce((a, b) => a + b, 0);
|
||
const isNightOwl = nightOwlMinutes > totalMinutes * 0.3;
|
||
const isEarlyBird = earlyBirdMinutes > totalMinutes * 0.3;
|
||
|
||
// Watching streaks
|
||
let longestStreak = 0, currentStreak = 0;
|
||
for (let i = 0; i < dailyActivity.length; i++) {
|
||
if (i === 0) { currentStreak = 1; }
|
||
else {
|
||
const prev = new Date(dailyActivity[i - 1].watch_date);
|
||
const curr = new Date(dailyActivity[i].watch_date);
|
||
const diffDays = Math.round((curr - prev) / (1000 * 60 * 60 * 24));
|
||
currentStreak = diffDays === 1 ? currentStreak + 1 : 1;
|
||
}
|
||
longestStreak = Math.max(longestStreak, currentStreak);
|
||
}
|
||
|
||
// Genre analysis
|
||
const genreMinutes = {};
|
||
enrichedForGenres.forEach(item => {
|
||
const mins = parseInt(item.duration);
|
||
(item.genres || []).forEach(genre => {
|
||
genreMinutes[genre] = (genreMinutes[genre] || 0) + mins;
|
||
});
|
||
});
|
||
const topGenres = Object.entries(genreMinutes)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.map(([name, minutes]) => ({ name, minutes, percent: Math.round((minutes / totalMinutes) * 100) }));
|
||
|
||
// Monthly graph (all 12 months)
|
||
const monthMap = {};
|
||
monthlyStats.forEach(m => { monthMap[m.month] = parseInt(m.duration); });
|
||
const monthlyGraph = Array.from({ length: 12 }, (_, i) => ({ month: i + 1, minutes: monthMap[i + 1] || 0 }));
|
||
|
||
// Session stats
|
||
const avgSessionMinutes = Math.round(totalMinutes / Math.max(totalSessions, 1));
|
||
|
||
// Base stats
|
||
const totalHours = Math.round(totalMinutes / 60);
|
||
const totalDays = parseFloat((totalMinutes / 60 / 24).toFixed(1));
|
||
const totalDurationLabel = formatDuration(totalMinutes);
|
||
|
||
// Dominant type
|
||
const dominantType = typeStats[0] || { content_type: 'movie', duration: 0, count: 0 };
|
||
const dominantPercent = totalMinutes > 0 ? Math.round((parseInt(dominantType.duration) / totalMinutes) * 100) : 0;
|
||
|
||
// Top content
|
||
const topShow = enrichedTopContent[0] || null;
|
||
const topShowMinutes = topShow ? parseInt(topShow.duration) : 0;
|
||
const topShowHours = Math.round(topShowMinutes / 60);
|
||
const topShowDurationLabel = formatDuration(topShowMinutes);
|
||
const topShowTitle = topShow ? topShow.content_title : null;
|
||
const topShowType = topShow ? topShow.content_type : null;
|
||
|
||
// Peak / lowest month
|
||
const peakMonth = monthlyStats[0] || { month: 1, duration: 0 };
|
||
const lowestMonth = monthlyStats[monthlyStats.length - 1] || peakMonth;
|
||
const monthNames = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||
|
||
// Diversity / loyalty / binge
|
||
const diversityScore = totalHours > 0 ? (uniqueTitles / totalHours) : 0;
|
||
const isExplorer = diversityScore > 0.5;
|
||
const isLoyal = diversityScore < 0.1 && uniqueTitles < 20;
|
||
const isBinger = topShowHours > totalHours * 0.3;
|
||
|
||
// Type percents
|
||
const animeStats = typeStats.find(t => t.content_type === 'anime');
|
||
const movieStats = typeStats.find(t => t.content_type === 'movie');
|
||
const tvStats = typeStats.find(t => t.content_type === 'tv');
|
||
|
||
const animePercent = animeStats ? Math.round((parseInt(animeStats.duration) / totalMinutes) * 100) : 0;
|
||
const moviePercent = movieStats ? Math.round((parseInt(movieStats.duration) / totalMinutes) * 100) : 0;
|
||
const tvPercent = tvStats ? Math.round((parseInt(tvStats.duration) / totalMinutes) * 100) : 0;
|
||
|
||
// Jour record / weekday / rewatch
|
||
const recordDayRow = recordDayRows[0] || null;
|
||
const recordDay = recordDayRow
|
||
? { date: recordDayRow.day, minutes: parseInt(recordDayRow.minutes) }
|
||
: null;
|
||
|
||
const weekdayMap = new Array(7).fill(0); // index 0 = dimanche … 6 = samedi
|
||
weekdayRows.forEach(r => { weekdayMap[r.dow - 1] = parseInt(r.minutes); });
|
||
const weekday = weekdayMap.map((minutes, i) => ({ dow: i + 1, minutes }));
|
||
|
||
const rewatchRow = rewatchRows[0] || null;
|
||
|
||
// Âge ciné : année médiane de sortie, pondérée par le temps de visionnage (top 10)
|
||
const datedItems = enrichedForGenres.filter(c => c.release_year);
|
||
let watchAgeYear = null;
|
||
if (datedItems.length >= 5) {
|
||
const sortedByYear = [...datedItems].sort((a, b) => a.release_year - b.release_year);
|
||
const totalWeight = sortedByYear.reduce((s, c) => s + parseInt(c.duration), 0);
|
||
let acc = 0;
|
||
for (const c of sortedByYear) {
|
||
acc += parseInt(c.duration);
|
||
if (acc >= totalWeight / 2) { watchAgeYear = c.release_year; break; }
|
||
}
|
||
}
|
||
|
||
let rewatchData = null;
|
||
if (rewatchRow) {
|
||
let rwTitle = rewatchRow.content_title;
|
||
if (!rwTitle) {
|
||
const cached = tmdbCache.get(`${rewatchRow.content_type}:${rewatchRow.content_id}`);
|
||
if (cached) rwTitle = cached.title;
|
||
else {
|
||
try {
|
||
const d = await fetchTMDBDetails(rewatchRow.content_id, rewatchRow.content_type);
|
||
rwTitle = d?.title || null;
|
||
} catch { /* slide skippée si pas de titre */ }
|
||
}
|
||
}
|
||
if (rwTitle) rewatchData = { title: rwTitle, type: rewatchRow.content_type, count: parseInt(rewatchRow.distinct_days) };
|
||
}
|
||
|
||
_t.computeEnd = Date.now();
|
||
console.log(`[Wrapped][PERF] 🧮 Compute stats: ${_t.computeEnd - _t.computeStart}ms`);
|
||
|
||
// ── 4. Persona + slides (pure CPU) ──────────────────────────────────────
|
||
|
||
const persona = determinePersona({
|
||
totalHours, uniqueTitles, dominantPercent, dominantType,
|
||
animePercent, moviePercent, tvPercent, isExplorer, isLoyal, isBinger,
|
||
topShowType, topShowHours, isNightOwl, isEarlyBird,
|
||
topGenres, avgSessionMinutes, longestStreak, totalSessions, percentile
|
||
});
|
||
|
||
const slides = generateSlides({
|
||
totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles,
|
||
topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, topShowType,
|
||
dominantType, dominantPercent,
|
||
peakMonth, lowestMonth, monthNames,
|
||
animePercent, moviePercent, tvPercent,
|
||
isExplorer, isLoyal, isBinger,
|
||
enrichedTopContent, typeStats, persona,
|
||
year, userId,
|
||
percentile, peakHour, isNightOwl, isEarlyBird,
|
||
longestStreak, totalActiveDays,
|
||
firstWatchData, lastWatchData,
|
||
topGenres, avgSessionMinutes, totalSessions,
|
||
recordDay, rewatchData, watchAgeYear, topPages, weekday
|
||
});
|
||
|
||
_t.slidesEnd = Date.now();
|
||
console.log(`[Wrapped][PERF] 🎨 Persona + slides: ${_t.slidesEnd - _t.computeEnd}ms`);
|
||
|
||
// ── 5. Build response ───────────────────────────────────────────────────
|
||
|
||
const wrapped = {
|
||
year: parseInt(year),
|
||
persona,
|
||
slides,
|
||
stats: {
|
||
totalMinutes, totalHours, totalDays, uniqueTitles,
|
||
totalSessions,
|
||
avgSessionMinutes, totalActiveDays, longestStreak, percentile
|
||
},
|
||
topContent: enrichedTopContent.map((c, i) => {
|
||
const mins = parseInt(c.duration);
|
||
return {
|
||
rank: i + 1,
|
||
title: c.content_title,
|
||
type: c.content_type,
|
||
minutes: mins,
|
||
hours: Math.round(mins / 60),
|
||
durationLabel: formatDurationShort(mins),
|
||
tmdbId: c.content_type !== 'live-tv' ? parseInt(c.content_id) : null,
|
||
poster_path: c.poster_path,
|
||
backdrop_path: c.backdrop_path || null,
|
||
year: c.release_year || null,
|
||
vote_average: c.vote_average || null,
|
||
genres: c.genres || []
|
||
};
|
||
}),
|
||
byType: typeStats.map(t => ({
|
||
type: t.content_type,
|
||
minutes: parseInt(t.duration),
|
||
count: parseInt(t.count),
|
||
percent: Math.round((parseInt(t.duration) / totalMinutes) * 100)
|
||
})),
|
||
topGenres,
|
||
peakMonth: { month: peakMonth.month, name: monthNames[peakMonth.month], minutes: parseInt(peakMonth.duration) },
|
||
monthlyGraph,
|
||
listeningClock: hourlyMap.map((minutes, hour) => ({ hour, minutes })),
|
||
peakHour,
|
||
firstWatch: firstWatchData ? {
|
||
title: firstWatchData.content_title, type: firstWatchData.content_type,
|
||
date: firstWatchData.created_at,
|
||
tmdbId: firstWatchData.content_type !== 'live-tv' ? parseInt(firstWatchData.content_id) : null
|
||
} : null,
|
||
lastWatch: lastWatchData ? {
|
||
title: lastWatchData.content_title, type: lastWatchData.content_type,
|
||
date: lastWatchData.created_at,
|
||
tmdbId: lastWatchData.content_type !== 'live-tv' ? parseInt(lastWatchData.content_id) : null
|
||
} : null,
|
||
topPages: topPages.map(p => ({ page: p.page_name, minutes: parseInt(p.duration) })),
|
||
recordDay,
|
||
weekday,
|
||
rewatch: rewatchData,
|
||
watchAgeYear
|
||
};
|
||
|
||
// ── 6. Store in Redis cache (fire-and-forget) ───────────────────────────
|
||
const responsePayload = { success: true, wrapped, progress };
|
||
redisSet(cacheKey, JSON.stringify(responsePayload), WRAPPED_CACHE_TTL);
|
||
|
||
const totalTime = Date.now() - _t.start;
|
||
console.log(`[Wrapped][PERF] ── DONE in ${totalTime}ms ── Breakdown: SQL+Pctile(parallel)=${_t.sqlEnd - _t.sqlStart}ms | TMDB=${_t.tmdbEnd - _t.tmdbStart}ms | Compute=${_t.computeEnd - _t.computeStart}ms | Slides=${_t.slidesEnd - _t.computeEnd}ms | Build+Send=${Date.now() - _t.slidesEnd}ms`);
|
||
|
||
res.json(responsePayload);
|
||
|
||
} catch (error) {
|
||
console.error('[Wrapped] Error generating wrapped:', error);
|
||
res.status(500).json({ success: false, error: 'Generation failed' });
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// === FONCTIONS DE GÉNÉRATION TEMPLATES ===
|
||
// ============================================
|
||
|
||
// ─── Sélection déterministe (hash FNV-1a) ───────────────────────────────────
|
||
// Même Wrapped à chaque ouverture pour un même user/année (screenshots stables).
|
||
function fnv1aHash(str) {
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash ^= str.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
return hash >>> 0;
|
||
}
|
||
|
||
function seededShuffle(array, seedStr) {
|
||
const arr = [...array];
|
||
let seed = fnv1aHash(seedStr);
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
seed = Math.imul(seed ^ (seed >>> 15), 0x01000193) >>> 0;
|
||
const j = seed % (i + 1);
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
// ─── Personas par genre dominant (genre #1 ≥ 30% du temps) ──────────────────
|
||
const GENRE_PERSONAS = [
|
||
{ keys: ['horreur', 'horror', 'épouvante'], persona: { id: 'horror', title: 'L\'Accro aux Frissons', emoji: '👻', subtitle: 'Tu dors la lumière allumée, et alors ?', description: 'L\'horreur, c\'est ton terrain de jeu.', color: '#B71C1C' } },
|
||
{ keys: ['thriller', 'crime', 'mystère', 'mystery', 'policier'], persona: { id: 'thriller', title: 'Le Profiler', emoji: '🕵️', subtitle: 'Tu résous l\'enquête avant le générique', description: 'Le suspense, tu carbures à ça.', color: '#37474F' } },
|
||
{ keys: ['romance'], persona: { id: 'romance', title: 'Le Grand Romantique', emoji: '💘', subtitle: 'Tu crois encore au grand amour', description: 'Une belle histoire et tu fonds.', color: '#EC407A' } },
|
||
{ keys: ['comédie', 'comedy'], persona: { id: 'comedy', title: 'Le Roi du Rire', emoji: '😎', subtitle: 'La vie est trop courte pour le drame', description: 'Tu choisis toujours l\'option qui fait marrer.', color: '#FBC02D' } },
|
||
{ keys: ['science-fiction', 'science fiction', 'sci-fi', 'fantastique', 'fantasy'], persona: { id: 'scifi', title: 'Le Voyageur', emoji: '🚀', subtitle: 'Tu vis mieux dans d\'autres mondes', description: 'Plus à l\'aise dans le futur qu\'au présent.', color: '#7E57C2' } },
|
||
{ keys: ['documentaire', 'documentary'], persona: { id: 'documentary', title: 'L\'Éternel Curieux', emoji: '🔬', subtitle: 'Tu apprends un truc par épisode', description: 'Le réel te passionne plus que la fiction.', color: '#00897B' } },
|
||
{ keys: ['action', 'aventure', 'adventure'], persona: { id: 'action', title: 'L\'Accro à l\'Adrénaline', emoji: '💥', subtitle: 'Plus ça explose, plus t\'es content', description: 'Tu carbures aux poursuites et aux gros boums.', color: '#F4511E' } },
|
||
{ keys: ['drame', 'drama'], persona: { id: 'drama', title: 'L\'Âme Sensible', emoji: '🎭', subtitle: 'Tu chiales et t\'assumes', description: 'Les belles histoires, ça te remue.', color: '#5C6BC0' } },
|
||
{ keys: ['animation', 'familial', 'family'], persona: { id: 'animation', title: 'Le Grand Enfant', emoji: '🎨', subtitle: 'L\'animation, c\'est pas que pour les petits', description: 'Ton âme d\'enfant se porte très bien.', color: '#26A69A' } },
|
||
];
|
||
|
||
function matchGenrePersona(topGenres) {
|
||
const top = Array.isArray(topGenres) && topGenres[0];
|
||
if (!top || top.percent < 30 || !top.name) return null;
|
||
const name = top.name.toLowerCase();
|
||
for (const g of GENRE_PERSONAS) {
|
||
if (g.keys.some(k => name.includes(k))) return g.persona;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Détermine la personnalité de visionnage de l'utilisateur
|
||
* Basé sur des règles de classification (comme le ML de Spotify)
|
||
*/
|
||
function determinePersona(data) {
|
||
const {
|
||
totalHours, uniqueTitles, dominantPercent, dominantType,
|
||
animePercent, moviePercent, tvPercent, isExplorer, isLoyal, isBinger,
|
||
topShowType, topShowHours, isNightOwl, isEarlyBird,
|
||
topGenres, avgSessionMinutes, longestStreak, totalSessions, percentile
|
||
} = data;
|
||
|
||
const personas = [
|
||
// --- Intensité ---
|
||
{ condition: totalHours > 1000, persona: { id: 'legend', title: 'La Légende Vivante', emoji: '👑', subtitle: 'T\'as carrément habité sur Movix', description: 'Plus de 1000h. On devrait te salarier.', color: '#FFD700' } },
|
||
{ condition: totalHours > 500, persona: { id: 'marathon', title: 'Le Marathonien Ultime', emoji: '🏃', subtitle: 'Ton canapé a pris ta forme', description: 'T\'as fait du binge un sport olympique.', color: '#FF6B35' } },
|
||
{ condition: percentile != null && percentile >= 99 && totalHours > 200, persona: { id: 'elite-1pct', title: 'Le Top 1%', emoji: '💎', subtitle: 'Dans le club très fermé', description: 'Tu regardes plus que 99% des gens ici. Respect.', color: '#00E5FF' } },
|
||
// --- Rythme ---
|
||
{ condition: isNightOwl && totalHours > 100, persona: { id: 'night-owl', title: 'L\'Oiseau de Nuit', emoji: '🦉', subtitle: 'La nuit, ton écran est le seul allumé', description: 'Tes meilleures sessions ? Entre minuit et 5h.', color: '#1A237E' } },
|
||
{ condition: isEarlyBird && totalHours > 100, persona: { id: 'early-bird', title: 'Le Lève-tôt', emoji: '🌅', subtitle: 'Un épisode avant le café', description: 'Tu lances Movix quand les autres dorment encore.', color: '#FF8A65' } },
|
||
// --- Type dominant ---
|
||
{ condition: animePercent > 80, persona: { id: 'weeb-supreme', title: 'Weeb Suprême', emoji: '⛩️', subtitle: 'Tu penses en sous-titres', description: '+80% d\'anime. T\'es plus à Tokyo qu\'à Paris.', color: '#E91E63' } },
|
||
{ condition: animePercent > 50, persona: { id: 'otaku', title: 'L\'Otaku Assumé', emoji: '🍜', subtitle: 'Ton cœur bat au rythme des openings', description: 'L\'anime, c\'est pas une phase, c\'est un mode de vie.', color: '#9C27B0' } },
|
||
{ condition: moviePercent > 70 && uniqueTitles > 50, persona: { id: 'critic', title: 'Le Critique', emoji: '🎬', subtitle: 'Un avis sur tout, et souvent le bon', description: 'Tu pourrais noter des films pour de vrai.', color: '#2196F3' } },
|
||
{ condition: moviePercent > 70, persona: { id: 'cinephile', title: 'Le Cinéphile', emoji: '🎥', subtitle: 'Le 7e art coule dans tes veines', description: 'Les films, c\'est ta religion.', color: '#3F51B5' } },
|
||
{ condition: tvPercent > 70 && isBinger, persona: { id: 'binger', title: 'Le Binge-Watcher Pro', emoji: '📺', subtitle: '"Encore un épisode" = ton mantra', description: 'Tu regardes pas les séries, tu les dévores.', color: '#4CAF50' } },
|
||
{ condition: tvPercent > 50, persona: { id: 'series-addict', title: 'L\'Accro aux Séries', emoji: '📡', subtitle: 'Plus de persos fictifs que d\'amis (gentiment)', description: 'Les séries, c\'est ta deuxième famille.', color: '#009688' } },
|
||
];
|
||
|
||
for (const p of personas) {
|
||
if (p.condition === true) return p.persona;
|
||
}
|
||
|
||
// --- Genre dominant (si ≥30% du temps) ---
|
||
const genrePersona = matchGenrePersona(topGenres);
|
||
if (genrePersona) return genrePersona;
|
||
|
||
// --- Comportement ---
|
||
const behaviorPersonas = [
|
||
{ condition: isExplorer && uniqueTitles > 100, persona: { id: 'explorer-elite', title: 'L\'Explorateur d\'Élite', emoji: '🧭', subtitle: 'Tu connais des trucs que personne connaît', description: '+100 titres. La curiosité incarnée.', color: '#FF9800' } },
|
||
{ condition: isExplorer, persona: { id: 'explorer', title: 'L\'Explorateur', emoji: '🔍', subtitle: 'Toujours en chasse de la prochaine pépite', description: 'Tu préfères découvrir que revoir.', color: '#FFC107' } },
|
||
{ condition: totalSessions > 50 && avgSessionMinutes < 25, persona: { id: 'snacker', title: 'Le Grignoteur', emoji: '🍿', subtitle: 'Tu mates par petites bouchées', description: 'Sessions courtes mais souvent. Le snacking version streaming.', color: '#FF7043' } },
|
||
{ condition: longestStreak >= 14, persona: { id: 'streak-machine', title: 'La Machine', emoji: '⚙️', subtitle: 'Aucun jour off', description: 'T\'enchaînes les jours sans jamais lâcher.', color: '#607D8B' } },
|
||
{ condition: isLoyal && topShowHours > 50, persona: { id: 'superfan', title: 'Le Superfan', emoji: '💜', subtitle: 'T\'as trouvé TON truc, tu lâches plus', description: 'La loyauté, c\'est ta signature.', color: '#673AB7' } },
|
||
{ condition: isLoyal, persona: { id: 'comfort-watcher', title: 'L\'Amateur de Confort', emoji: '🛋️', subtitle: 'Pourquoi changer une équipe qui gagne ?', description: 'Tu remates tes classiques, et c\'est très bien.', color: '#795548' } },
|
||
{ condition: dominantPercent < 45, persona: { id: 'omnivore', title: 'Le Touche-à-tout', emoji: '🎲', subtitle: 'Films, séries, anime : tu prends tout', description: 'Aucune case te résume. L\'équilibre parfait.', color: '#26C6DA' } },
|
||
];
|
||
|
||
for (const p of behaviorPersonas) {
|
||
if (p.condition === true) return p.persona;
|
||
}
|
||
|
||
// --- Défauts par type dominant ---
|
||
const defaultPersonas = {
|
||
'anime': { id: 'anime-fan', title: 'L\'Anime Fan', emoji: '✨', subtitle: 'L\'animation japonaise te parle', description: 'Un bel équilibre d\'anime dans ta vie.', color: '#E91E63' },
|
||
'movie': { id: 'movie-lover', title: 'L\'Amoureux du Cinéma', emoji: '🍿', subtitle: 'Rien ne vaut un bon film', description: 'Tu sais apprécier une bonne histoire.', color: '#2196F3' },
|
||
'tv': { id: 'tv-enthusiast', title: 'L\'Enthousiaste des Séries', emoji: '📺', subtitle: 'Les séries rythment ta routine', description: 'Un épisode par jour éloigne le médecin.', color: '#4CAF50' },
|
||
'live-tv': { id: 'live-watcher', title: 'Le Téléspectateur', emoji: '📡', subtitle: 'Toujours branché sur le direct', description: 'La TV en direct, c\'est ton truc.', color: '#607D8B' }
|
||
};
|
||
|
||
return defaultPersonas[dominantType.content_type] || defaultPersonas['movie'];
|
||
}
|
||
|
||
/**
|
||
* Formate une durée en minutes : affiche les heures si >= 60 min, sinon les minutes
|
||
* @param {number} minutes
|
||
* @returns {string} e.g. "12 heures" ou "45 minutes"
|
||
*/
|
||
function formatDuration(minutes) {
|
||
if (minutes >= 60) {
|
||
const h = Math.round(minutes / 60);
|
||
return `${h} heure${h > 1 ? 's' : ''}`;
|
||
}
|
||
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||
}
|
||
|
||
/**
|
||
* Version courte : "12h" ou "45min"
|
||
*/
|
||
function formatDurationShort(minutes) {
|
||
if (minutes >= 60) {
|
||
return `${Math.round(minutes / 60)}h`;
|
||
}
|
||
return `${minutes}min`;
|
||
}
|
||
|
||
/**
|
||
* Génère les slides du Wrapped v2
|
||
*/
|
||
function generateSlides(data) {
|
||
const {
|
||
totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles,
|
||
topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel,
|
||
dominantType, dominantPercent,
|
||
peakMonth, lowestMonth, monthNames,
|
||
enrichedTopContent, persona,
|
||
year, userId,
|
||
percentile, peakHour, isNightOwl, isEarlyBird,
|
||
longestStreak, totalActiveDays,
|
||
firstWatchData,
|
||
topGenres, avgSessionMinutes,
|
||
isExplorer, isBinger,
|
||
recordDay, rewatchData, watchAgeYear, topPages, weekday
|
||
} = data;
|
||
|
||
const slides = [];
|
||
const seed = `${userId}:${year}`;
|
||
const shortLabel = formatDurationShort(totalMinutes);
|
||
// Variante seedée par user+slide : chaque utilisateur tombe sur une formulation
|
||
// différente, mais stable d'une ouverture à l'autre (screenshots reproductibles).
|
||
const pick = (key, variants) => variants[fnv1aHash(`${seed}:${key}`) % variants.length];
|
||
|
||
// === 1. INTRO ===
|
||
const introTemplates = [
|
||
{
|
||
condition: totalHours > 500,
|
||
title: pick('intro-t', [`${year}, hors catégorie.`, `${year}, niveau final.`]),
|
||
subtitle: 'On a recompté trois fois, c\'est réel.',
|
||
texts: [
|
||
`${totalDurationLabel} de visionnage, soit ${totalDays} jours complets devant un écran. T'es plus un utilisateur, t'es un pilier de Movix.`,
|
||
`${totalDurationLabel} cette année. Des séries entières sont nées et mortes pendant que toi, t'étais là.`,
|
||
`${totalDurationLabel} au compteur. Ton canapé devrait être classé monument historique.`
|
||
],
|
||
highlight: `${totalDays} jours`
|
||
},
|
||
{
|
||
condition: totalHours > 300,
|
||
title: pick('intro-t', [`${year}, du très lourd.`, `${year}, régime intensif.`]),
|
||
subtitle: 'Et c\'est peu de le dire.',
|
||
texts: [
|
||
`${totalDurationLabel} sur Movix, soit ${totalDays} jours non-stop. Le canapé a officiellement pris ta forme.`,
|
||
`${totalDurationLabel} de visionnage. Certains font des marathons. Toi, t'as couru toute l'année.`,
|
||
`${totalDurationLabel} cette année. Quelque part entre la passion et le record du monde.`
|
||
],
|
||
highlight: `${totalDays} jours`
|
||
},
|
||
{
|
||
condition: totalHours > 200,
|
||
title: `${year}, t'as tout donné.`,
|
||
subtitle: 'Et on a tout vu.',
|
||
texts: [
|
||
`${totalDurationLabel} sur Movix cette année. Soit ${totalDays} jours non-stop. À ce stade, c'est plus un hobby, c'est un mode de vie.`,
|
||
`${totalDurationLabel} de visionnage, ${totalDays} jours pleins. T'as pas regardé une année, t'en as vécu deux.`,
|
||
`${totalDurationLabel} au total. Si regarder était un sport, t'aurais une fédération à ton nom.`
|
||
],
|
||
highlight: `${totalDays} jours`
|
||
},
|
||
{
|
||
condition: totalHours > 100,
|
||
title: pick('intro-t', [`${year}, bien rempli.`, `${year}, sacré rythme.`]),
|
||
subtitle: 'Et ton historique le prouve.',
|
||
texts: [
|
||
`${totalDurationLabel} de visionnage. Genre, plus que certains mi-temps de boulot. Validé.`,
|
||
`${totalDurationLabel} cette année. T'as trouvé ton rythme de croisière, et il est soutenu.`,
|
||
`${totalDurationLabel} sur Movix. Une vraie deuxième vie, bien remplie.`
|
||
],
|
||
highlight: shortLabel
|
||
},
|
||
{
|
||
condition: totalHours > 50,
|
||
title: `Solide, ${year}.`,
|
||
subtitle: 'Vraiment solide.',
|
||
texts: [
|
||
`${totalDurationLabel} sur Movix. Tu sais ce que t'aimes, et tu fonces.`,
|
||
`${totalDurationLabel} de visionnage. Pas d'excès, pas de manque : l'équilibre du connaisseur.`,
|
||
`${totalDurationLabel} cette année. Régulier, précis, efficace.`
|
||
],
|
||
highlight: shortLabel
|
||
},
|
||
{
|
||
condition: totalHours > 20,
|
||
title: pick('intro-t', [`${year}, tranquille.`, `${year}, en finesse.`]),
|
||
subtitle: 'Mais efficace.',
|
||
texts: [
|
||
`${totalDurationLabel} bien choisies. Toi, tu regardes pas beaucoup — tu regardes bien.`,
|
||
`${totalDurationLabel} au compteur. La qualité avant la quantité, toujours.`,
|
||
`${totalDurationLabel} cette année. Chaque session compte, rien au hasard.`
|
||
],
|
||
highlight: shortLabel
|
||
},
|
||
{
|
||
condition: true,
|
||
title: `${year}, avec toi.`,
|
||
subtitle: 'Et c\'est déjà pas mal.',
|
||
texts: [
|
||
`${totalDurationLabel} ensemble cette année. Le début d'une belle histoire.`,
|
||
`${totalDurationLabel} passées ici. On espère que c'était que du bon.`
|
||
],
|
||
highlight: shortLabel
|
||
}
|
||
];
|
||
const introTpl = introTemplates.find(t => t.condition);
|
||
slides.push({ type: 'intro', title: introTpl.title, subtitle: introTpl.subtitle, text: pick('intro', introTpl.texts), highlight: introTpl.highlight });
|
||
|
||
// === 2. TIMELINE (remplace peak-month) ===
|
||
const peakMonthMinutes = parseInt(peakMonth.duration);
|
||
const peakMonthLabel = formatDuration(peakMonthMinutes);
|
||
const peakName = monthNames[peakMonth.month];
|
||
const lowestName = monthNames[lowestMonth.month];
|
||
const avgMonthlyMinutes = totalMinutes / 12;
|
||
const peakRatio = avgMonthlyMinutes > 0 ? peakMonthMinutes / avgMonthlyMinutes : 1;
|
||
let timelineText;
|
||
if (peakMonth.month === lowestMonth.month) {
|
||
timelineText = `${peakName}, ton pic absolu : ${peakMonthLabel} en un seul mois.`;
|
||
} else if (peakRatio >= 3) {
|
||
timelineText = pick('timeline', [
|
||
`${peakName} a tout écrasé : ${peakMonthLabel} à lui seul. Il s'est passé un truc en ${peakName.toLowerCase()}, avoue.`,
|
||
`${peakMonthLabel} rien qu'en ${peakName.toLowerCase()}. Le reste de l'année regardait de loin.`,
|
||
`${peakName} en mode rouleau compresseur (${peakMonthLabel}), pendant que ${lowestName.toLowerCase()} comptait les jours.`
|
||
]);
|
||
} else {
|
||
timelineText = pick('timeline', [
|
||
`${peakName} en feu (${peakMonthLabel}), ${lowestName.toLowerCase()} fantôme. Chacun son rythme.`,
|
||
`${peakName} au sommet avec ${peakMonthLabel}, ${lowestName.toLowerCase()} en mode avion. L'année a eu ses saisons.`,
|
||
`Gros pic en ${peakName.toLowerCase()} (${peakMonthLabel}), calme plat en ${lowestName.toLowerCase()}. Une année avec du relief.`
|
||
]);
|
||
}
|
||
slides.push({
|
||
type: 'timeline',
|
||
title: 'Ton année en courbe',
|
||
subtitle: `${peakName}, ton mois fort`,
|
||
text: timelineText,
|
||
highlight: '📈',
|
||
subtext: peakMonthMinutes > 3000 ? pick('timeline-s', ['On constate, on ne juge pas.', 'Aucun jugement. Beaucoup de respect.']) : ''
|
||
});
|
||
|
||
// === 3. TOP GENRES ===
|
||
if (topGenres && topGenres.length >= 2) {
|
||
const topGenreNames = topGenres.slice(0, 3).map(g => g.name).join(', ');
|
||
slides.push({
|
||
type: 'top-genres',
|
||
title: pick('genres-t', ['Tes genres de prédilection', 'Ton ADN de spectateur', 'Ta palette de l\'année']),
|
||
subtitle: topGenreNames,
|
||
text: topGenres.length >= 3
|
||
? pick('genres', [
|
||
`${topGenres[0].name} mène la danse avec ${topGenres[0].percent}% de ton temps. Juste derrière : ${topGenres[1].name} et ${topGenres[2].name}.`,
|
||
`${topGenres[0].name} en tête (${topGenres[0].percent}% de ton temps), talonné par ${topGenres[1].name} et ${topGenres[2].name}. Le trio gagnant.`,
|
||
`Ton podium des genres : ${topGenres[0].name} (${topGenres[0].percent}%), puis ${topGenres[1].name} et ${topGenres[2].name}. Une signature bien à toi.`
|
||
])
|
||
: `${topGenres[0].name} mène la danse avec ${topGenres[0].percent}% de ton temps.`,
|
||
highlight: '🎭',
|
||
subtext: ''
|
||
});
|
||
}
|
||
|
||
// === 4. TOP 1 (le quiz client s'insère juste avant côté frontend) ===
|
||
if (topShowTitle) {
|
||
const top1Templates = [
|
||
{
|
||
condition: topShowHours > 200,
|
||
subtitle: 'Ton année lui appartient',
|
||
texts: [
|
||
`${topShowDurationLabel} dessus. C'est plus du visionnage, c'est une colocation.`,
|
||
`${topShowDurationLabel} passées ensemble. Vous devriez officialiser, là.`,
|
||
`${topShowDurationLabel} sur ce titre. Tu pourrais doubler les personnages de mémoire.`
|
||
],
|
||
subtext: 'Iconique. Légèrement inquiétant. Surtout iconique.'
|
||
},
|
||
{
|
||
condition: topShowHours > 100,
|
||
subtitle: 'Ton obsession de l\'année',
|
||
texts: [
|
||
`${topShowDurationLabel} dessus. Tu pourrais en écrire la suite les yeux fermés.`,
|
||
`${topShowDurationLabel} passées dessus. Les acteurs eux-mêmes l'ont moins vu que toi.`,
|
||
`${topShowDurationLabel} sur ce titre. À ce niveau, c'est plus un programme, c'est un membre de la famille.`
|
||
],
|
||
subtext: 'Un peu gênant. Beaucoup iconique.'
|
||
},
|
||
{
|
||
condition: topShowHours > 50,
|
||
subtitle: 'Ton grand gagnant',
|
||
texts: [
|
||
`${topShowDurationLabel} ensemble. Vous êtes officiellement en couple.`,
|
||
`${topShowDurationLabel} dessus. Le genre de fidélité qui se fait rare.`,
|
||
`${topShowDurationLabel} passées dessus. Coup de cœur confirmé, et largement assumé.`
|
||
],
|
||
subtext: 'Fan n°1 ? C\'est toi.'
|
||
},
|
||
{
|
||
condition: topShowHours > 20,
|
||
subtitle: 'Ta grande histoire de l\'année',
|
||
texts: [
|
||
`${topShowDurationLabel} dessus. Plus que certaines vraies relations.`,
|
||
`${topShowDurationLabel} ensemble. Une belle histoire, sans drama (enfin, sauf à l'écran).`,
|
||
`${topShowDurationLabel} passées dessus. Vous deux, c'était évident.`
|
||
],
|
||
subtext: 'On ne juge pas.'
|
||
},
|
||
{
|
||
condition: true,
|
||
subtitle: 'Ton préféré de l\'année',
|
||
texts: [
|
||
`${topShowDurationLabel} passées dessus. Un classique gravé dans ton cœur.`,
|
||
`${topShowDurationLabel} dessus. Le titre qui a gagné ton année, tout simplement.`
|
||
],
|
||
subtext: ''
|
||
}
|
||
];
|
||
const tpl = top1Templates.find(t => t.condition);
|
||
slides.push({ type: 'top1', title: `"${topShowTitle}"`, subtitle: tpl.subtitle, text: pick('top1', tpl.texts), highlight: '#1', subtext: tpl.subtext });
|
||
}
|
||
|
||
// === 5. TOP 5 ===
|
||
if (enrichedTopContent.length >= 3) {
|
||
const top5Text = enrichedTopContent.slice(0, 5).map((c, i) => `${i + 1}. ${c.content_title}`).join('\n');
|
||
slides.push({
|
||
type: 'top5',
|
||
title: 'Ton Top 5',
|
||
subtitle: pick('top5-s', ['Le casting de ton année', 'Tes têtes d\'affiche', 'Le grand palmarès']),
|
||
text: top5Text,
|
||
highlight: `${uniqueTitles} titres en tout`,
|
||
subtext: uniqueTitles > 200
|
||
? pick('top5-x', ['Encyclopédie vivante.', 'Une vidéothèque entière à toi tout seul.'])
|
||
: uniqueTitles > 50 ? 'Incollable.' : ''
|
||
});
|
||
}
|
||
|
||
// === 6. REWATCH (conditionnelle) ===
|
||
if (rewatchData) {
|
||
const rwTexts = rewatchData.count >= 5
|
||
? [
|
||
`${rewatchData.count} fois sur "${rewatchData.title}" cette année. Tu connais les répliques avant les acteurs.`,
|
||
`T'es revenu ${rewatchData.count} fois sur "${rewatchData.title}". C'est plus un rewatch, c'est un pèlerinage.`
|
||
]
|
||
: rewatchData.count >= 3
|
||
? [
|
||
`T'es revenu ${rewatchData.count} fois sur "${rewatchData.title}". Le confort a un nom.`,
|
||
`${rewatchData.count} visites à "${rewatchData.title}" cette année. Quand on aime, on recompte pas.`
|
||
]
|
||
: [
|
||
`Deux fois sur "${rewatchData.title}" cette année. Quand c'est bon, c'est bon.`,
|
||
`"${rewatchData.title}", vu et revu. Certaines histoires méritent une deuxième séance.`
|
||
];
|
||
slides.push({
|
||
type: 'rewatch',
|
||
title: 'Ton revenant',
|
||
subtitle: rewatchData.type === 'movie' ? 'Le film qui te lâche pas' : 'L\'épisode que tu relances en boucle',
|
||
text: pick('rewatch', rwTexts),
|
||
highlight: `×${rewatchData.count}`,
|
||
subtext: pick('rewatch-s', ['Les classiques, ça se respecte.', 'La valeur sûre, c\'est sacré.'])
|
||
});
|
||
}
|
||
|
||
// === 7. RECORD DAY (conditionnelle, > 3h) ===
|
||
if (recordDay && recordDay.minutes > 180) {
|
||
const recordDate = new Date(recordDay.date);
|
||
const recordDateLabel = recordDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||
const recordLabel = formatDuration(recordDay.minutes);
|
||
const recordTpl = recordDay.minutes > 480
|
||
? {
|
||
title: 'Ta journée hors norme',
|
||
texts: [
|
||
`${recordLabel} en une seule journée. Une performance. La médaille arrive par la poste.`,
|
||
`${recordLabel} d'affilée ce jour-là. Le soleil s'est levé et couché sans toi.`,
|
||
`${recordLabel} en 24h. On appelle plus ça une journée, on appelle ça un festival.`
|
||
],
|
||
subtext: 'Journée rentrée dans la légende.'
|
||
}
|
||
: recordDay.minutes > 300
|
||
? {
|
||
title: 'Ta journée légendaire',
|
||
texts: [
|
||
`${recordLabel} en une seule journée. On sait pas ce qui s'est passé, mais c'était du sérieux.`,
|
||
`${recordLabel} ce jour-là. Pluie dehors ou grosse flemme : dans tous les cas, bien joué.`,
|
||
`${recordLabel} en un jour. La définition même du jour parfait.`
|
||
],
|
||
subtext: 'Journée certifiée canapé.'
|
||
}
|
||
: {
|
||
title: 'Ta plus grosse journée',
|
||
texts: [
|
||
`${recordLabel} en une journée. Ton record perso de l'année.`,
|
||
`${recordLabel} ce jour-là. Une après-midi bien investie.`
|
||
],
|
||
subtext: ''
|
||
};
|
||
slides.push({
|
||
type: 'record-day',
|
||
title: recordTpl.title,
|
||
subtitle: `Le ${recordDateLabel}`,
|
||
text: pick('record', recordTpl.texts),
|
||
highlight: formatDurationShort(recordDay.minutes),
|
||
subtext: recordTpl.subtext
|
||
});
|
||
}
|
||
|
||
// === 8. LISTENING CLOCK (+ weekday rendu côté front) ===
|
||
const hourLabel = (h) => `${h}h`;
|
||
const clockTemplates = [
|
||
{
|
||
condition: isNightOwl,
|
||
title: 'Team nuit',
|
||
subtitle: `Ton pic : ${hourLabel(peakHour)}`,
|
||
texts: [
|
||
`Pendant que le monde dort, toi tu lances un épisode. Les cernes, c'est le prix de la passion.`,
|
||
`Tes meilleures sessions commencent quand les autres éteignent. La nuit te réussit.`,
|
||
`Minuit passé, un épisode de plus. Le club des noctambules te salue.`
|
||
],
|
||
highlight: '🌙',
|
||
subtext: 'Les meilleures sessions, c\'est la nuit.'
|
||
},
|
||
{
|
||
condition: isEarlyBird,
|
||
title: 'Team matin',
|
||
subtitle: `Ton pic : ${hourLabel(peakHour)}`,
|
||
texts: [
|
||
`Un épisode avant même le café. Respect total.`,
|
||
`Toi, tu commences la journée par un générique. Et franchement, c'est une bonne routine.`
|
||
],
|
||
highlight: '🌅',
|
||
subtext: ''
|
||
},
|
||
{
|
||
condition: peakHour >= 18 && peakHour <= 23,
|
||
title: 'Team prime time',
|
||
subtitle: `Ton pic : ${hourLabel(peakHour)}`,
|
||
texts: [
|
||
`${hourLabel(peakHour)}, le canapé t'appelle, et tu réponds toujours présent. La grande tradition de la soirée écran.`,
|
||
`Le soir venu, c'est ton moment. ${hourLabel(peakHour)} pétantes, et c'est parti.`,
|
||
`Ta journée se termine toujours pareil : ${hourLabel(peakHour)}, plaid, lecture. La routine parfaite.`
|
||
],
|
||
highlight: '🛋️',
|
||
subtext: ''
|
||
},
|
||
{
|
||
condition: peakHour >= 12 && peakHour < 18,
|
||
title: 'Team après-midi',
|
||
subtitle: `Ton pic : ${hourLabel(peakHour)}`,
|
||
texts: [
|
||
`C'est en pleine après-midi que tu lances le plus souvent Movix. La pause de ${hourLabel(peakHour)}, c'est sacré.`,
|
||
`${hourLabel(peakHour)} : l'heure où ta journée fait une pause et où Movix prend le relais.`
|
||
],
|
||
highlight: '☀️',
|
||
subtext: ''
|
||
},
|
||
{
|
||
condition: true,
|
||
title: `${hourLabel(peakHour)}, ton heure de pointe`,
|
||
subtitle: 'Ton horloge Movix',
|
||
texts: [
|
||
`C'est vers ${hourLabel(peakHour)} que tu lances Movix le plus souvent. On connaît tes habitudes maintenant.`,
|
||
`${hourLabel(peakHour)}, ton rendez-vous quotidien. La ponctualité, c'est une qualité.`
|
||
],
|
||
highlight: '⏰',
|
||
subtext: ''
|
||
}
|
||
];
|
||
const clockTpl = clockTemplates.find(t => t.condition);
|
||
slides.push({ type: 'listening-clock', title: clockTpl.title, subtitle: clockTpl.subtitle, text: pick('clock', clockTpl.texts), highlight: clockTpl.highlight, subtext: clockTpl.subtext });
|
||
|
||
// === 9. STREAK (durées réelles : mois/semaines calculés, pas d'à-peu-près) ===
|
||
if (longestStreak >= 3) {
|
||
const streakMonths = Math.floor(longestStreak / 30);
|
||
const streakWeeks = Math.floor(longestStreak / 7);
|
||
const streakTemplates = [
|
||
{
|
||
condition: longestStreak >= 90,
|
||
title: `${longestStreak} jours d'affilée`,
|
||
subtitle: `${streakMonths} mois sans lâcher`,
|
||
texts: [
|
||
`${longestStreak} jours consécutifs, soit ${streakMonths} mois pleins sans en rater un seul. C'est plus de la constance, c'est un serment.`,
|
||
`${longestStreak} jours d'affilée. Des saisons entières ont commencé et fini pendant ta série.`
|
||
],
|
||
highlight: '🏆',
|
||
subtext: `Sur ${totalActiveDays} jours actifs cette année.`
|
||
},
|
||
{
|
||
condition: longestStreak >= 60,
|
||
title: `${longestStreak} jours d'affilée`,
|
||
subtitle: `${streakMonths} mois complets, jour après jour`,
|
||
texts: [
|
||
`${longestStreak} jours consécutifs, soit ${streakMonths} mois entiers sans interruption. C'est plus une habitude, c'est un mode de vie.`,
|
||
`${longestStreak} jours d'affilée. Le soleil a eu plus de jours off que toi.`,
|
||
`${longestStreak} jours sans en manquer un. ${streakMonths} mois de fidélité absolue, ça se signe quelque part ?`
|
||
],
|
||
highlight: '🔥',
|
||
subtext: `Sur ${totalActiveDays} jours actifs cette année.`
|
||
},
|
||
{
|
||
condition: longestStreak >= 30,
|
||
title: `${longestStreak} jours d'affilée`,
|
||
subtitle: 'Un mois entier non-stop',
|
||
texts: [
|
||
`Ta plus longue série : ${longestStreak} jours consécutifs. On appelle ça une légende.`,
|
||
`${longestStreak} jours sans interruption. Un mois complet de rendez-vous quotidien.`
|
||
],
|
||
highlight: '🔥',
|
||
subtext: `Sur ${totalActiveDays} jours actifs cette année.`
|
||
},
|
||
{
|
||
condition: longestStreak >= 14,
|
||
title: `${longestStreak} jours non-stop`,
|
||
subtitle: `${streakWeeks} semaines sans lâcher`,
|
||
texts: [
|
||
`${streakWeeks} semaines sans lâcher Movix. Discipline de fer (pour le streaming au moins).`,
|
||
`${longestStreak} jours d'affilée. La régularité d'une montre suisse, le plaisir en plus.`
|
||
],
|
||
highlight: '⚡',
|
||
subtext: `${totalActiveDays} jours actifs au total.`
|
||
},
|
||
{
|
||
condition: longestStreak >= 7,
|
||
title: `${longestStreak} jours non-stop`,
|
||
subtitle: 'Une semaine de dévotion',
|
||
texts: [
|
||
`${longestStreak} jours sans interruption. Ça, c'est de la constance.`,
|
||
`Une semaine entière sans rater un jour. Le rituel était bien rodé.`
|
||
],
|
||
highlight: '💪',
|
||
subtext: ''
|
||
},
|
||
{
|
||
condition: true,
|
||
title: `${longestStreak} jours de streak`,
|
||
subtitle: 'Ta meilleure série',
|
||
texts: [
|
||
`${longestStreak} jours consécutifs. Pas mal du tout.`,
|
||
`${longestStreak} jours d'affilée. Une jolie petite série.`
|
||
],
|
||
highlight: '🎯',
|
||
subtext: ''
|
||
}
|
||
];
|
||
const streakTpl = streakTemplates.find(t => t.condition);
|
||
slides.push({ type: 'streak', title: streakTpl.title, subtitle: streakTpl.subtitle, text: pick('streak', streakTpl.texts), highlight: streakTpl.highlight, subtext: streakTpl.subtext });
|
||
}
|
||
|
||
// === 10. PAGES TIME (conditionnelle, ≥ 60 min hors live-tv) ===
|
||
const pageLabels = {
|
||
'home': 'la page d\'accueil', 'movies': 'les films', 'tv-shows': 'les séries',
|
||
'movie-details': 'les fiches films', 'tv-details': 'les fiches séries',
|
||
'wishboard': 'le wishboard', 'watchparty': 'les watchparties', 'anime': 'les animes'
|
||
};
|
||
const browsePages = (topPages || []).filter(p => p.page_name !== 'live-tv' && pageLabels[p.page_name]);
|
||
const browseTotal = browsePages.reduce((s, p) => s + parseInt(p.duration), 0);
|
||
if (browsePages.length > 0 && browseTotal >= 60) {
|
||
const topPage = browsePages[0];
|
||
const browseLabel = formatDuration(browseTotal);
|
||
const pageTexts = {
|
||
'home': [
|
||
`${browseLabel} à scroller la page d'accueil. Hésiter, re-scroller, hésiter encore : un art de vivre.`,
|
||
`${browseLabel} sur la home avant de te décider. Le choix, c'est toute une aventure.`
|
||
],
|
||
'movie-details': [
|
||
`${browseLabel} à éplucher les fiches films. Toi, tu lances jamais rien sans avoir lu le dossier complet.`,
|
||
`${browseLabel} sur les fiches. Synopsis, note, casting : rien ne t'échappe avant le premier clic.`
|
||
],
|
||
'tv-details': [
|
||
`${browseLabel} à éplucher les fiches séries. Toi, tu lances jamais rien sans avoir lu le dossier complet.`,
|
||
`${browseLabel} sur les fiches. Synopsis, note, saisons : rien ne t'échappe avant le premier clic.`
|
||
],
|
||
'wishboard': [
|
||
`${browseLabel} sur ton wishboard. Collectionner les envies, c'est déjà la moitié du plaisir.`,
|
||
`${browseLabel} à organiser ta liste. Ta pile « à voir » est une œuvre en soi.`
|
||
],
|
||
'watchparty': [
|
||
`${browseLabel} en watchparty. Regarder seul ? Très peu pour toi.`,
|
||
`${browseLabel} passées à mater à plusieurs. Le cinéma, c'est mieux accompagné.`
|
||
]
|
||
};
|
||
const genericTexts = [
|
||
`${browseLabel} à naviguer sur Movix, surtout sur ${pageLabels[topPage.page_name]}. L'art de choisir, c'est tout un sport.`,
|
||
`${browseLabel} de balade dans le catalogue, ${pageLabels[topPage.page_name]} en tête. Flâner, c'est déjà regarder un peu.`
|
||
];
|
||
slides.push({
|
||
type: 'pages-time',
|
||
title: 'Là où tu traînes',
|
||
subtitle: 'Avant même de lancer un titre',
|
||
text: pick('pages', pageTexts[topPage.page_name] || genericTexts),
|
||
highlight: formatDurationShort(browseTotal),
|
||
subtext: pick('pages-s', ['L\'indécision, on connaît.', 'Le lèche-vitrine version streaming.'])
|
||
});
|
||
}
|
||
|
||
// === 11. WATCH AGE (conditionnelle, paliers d'époque) ===
|
||
if (watchAgeYear) {
|
||
const ageTpl = watchAgeYear >= year - 2
|
||
? {
|
||
title: 'Toujours sur la hype',
|
||
texts: [
|
||
`La moitié de ton temps sur des titres tout frais. T'es la hype incarnée.`,
|
||
`Tu regardes ce qui vient de sortir, point. Toujours au courant avant tout le monde.`
|
||
]
|
||
}
|
||
: watchAgeYear < 2000
|
||
? {
|
||
title: `Ton cœur vit en ${watchAgeYear}`,
|
||
texts: [
|
||
`La moitié de ton temps sur des titres d'avant ${watchAgeYear}. Le grain de l'ancien, le charme du classique : du patrimoine à l'état pur.`,
|
||
`Ton année médiane : ${watchAgeYear}. Pendant que tout le monde court après les nouveautés, toi tu sirotes les classiques.`
|
||
]
|
||
}
|
||
: watchAgeYear < 2015
|
||
? {
|
||
title: `Ton cœur vit en ${watchAgeYear}`,
|
||
texts: [
|
||
`La moitié de ton temps sur des titres d'avant ${watchAgeYear}. La nostalgie te va bien.`,
|
||
`${watchAgeYear}, ton point d'équilibre. Cette époque-là avait quelque chose, pas vrai ?`
|
||
]
|
||
}
|
||
: {
|
||
title: `Ton cœur vit en ${watchAgeYear}`,
|
||
texts: [
|
||
`Entre deux époques : la moitié de ton visionnage date d'avant ${watchAgeYear}. Le meilleur des deux mondes.`,
|
||
`${watchAgeYear} en année médiane. Ni tout neuf, ni vintage : juste ce qui te plaît.`
|
||
]
|
||
};
|
||
slides.push({
|
||
type: 'watch-age',
|
||
title: ageTpl.title,
|
||
subtitle: 'Ton âge ciné',
|
||
text: pick('age', ageTpl.texts),
|
||
highlight: String(watchAgeYear),
|
||
subtext: ''
|
||
});
|
||
}
|
||
|
||
// === 12. FUN FACTS (pool élargi, sélection seedée — 1, +1 si > 50h) ===
|
||
const funFacts = [];
|
||
if (totalHours > 24) {
|
||
const equivalent = Math.floor(totalHours / 2);
|
||
funFacts.push({
|
||
title: 'En vrai...', subtitle: '',
|
||
text: pick('ff-eq', [
|
||
`T'aurais pu enchaîner ${equivalent} films. Ou dormir ${totalDays} jours. T'as fait un choix.`,
|
||
`${totalDurationLabel}, c'est ${equivalent} films bout à bout. Ou ${Math.max(2, Math.floor(totalHours / 12))} vols Paris-Tokyo. T'as choisi le canapé, et on comprend.`
|
||
]),
|
||
highlight: '🤔', subtext: 'Le bon.'
|
||
});
|
||
} else if (totalMinutes > 30) {
|
||
funFacts.push({ title: 'En vrai...', subtitle: '', text: `${totalDurationLabel} de streaming. Joli début pour cette année.`, highlight: '🤔', subtext: 'Et c\'est que le début.' });
|
||
}
|
||
if (isExplorer && uniqueTitles > 30) {
|
||
funFacts.push({
|
||
title: 'L\'algo t\'adore', subtitle: '',
|
||
text: pick('ff-explo', [
|
||
`${uniqueTitles} titres différents. Tu rends nos recommandations folles (dans le bon sens).`,
|
||
`${uniqueTitles} titres explorés cette année. Notre catalogue te dit merci pour la visite guidée.`
|
||
]),
|
||
highlight: '🧠', subtext: ''
|
||
});
|
||
}
|
||
if (isBinger && topShowTitle) {
|
||
const percentOfTotal = totalMinutes > 0 ? Math.round((topShowMinutes / totalMinutes) * 100) : 0;
|
||
funFacts.push({
|
||
title: 'Petite confession', subtitle: '',
|
||
text: pick('ff-binge', [
|
||
`"${topShowTitle}" = ${percentOfTotal}% de ton temps total. Ça, c'est de l'engagement.`,
|
||
`${percentOfTotal}% de ton année entière sur "${topShowTitle}". Un placement assumé.`
|
||
]),
|
||
highlight: '💍', subtext: 'Ou de l\'obsession. On compte pas.'
|
||
});
|
||
}
|
||
if (percentile != null && percentile >= 90) {
|
||
funFacts.push({ title: `Top ${100 - percentile}% des viewers`, subtitle: 'Carrément l\'élite', text: `Tu regardes plus que ${percentile}% des gens sur Movix. Médaille méritée.`, highlight: '🏆', subtext: '' });
|
||
}
|
||
if (avgSessionMinutes > 90) {
|
||
funFacts.push({
|
||
title: 'Sessions marathon', subtitle: '',
|
||
text: pick('ff-sess', [
|
||
`Tes sessions durent ${formatDuration(avgSessionMinutes)} en moyenne. Tu fais pas les choses à moitié.`,
|
||
`${formatDuration(avgSessionMinutes)} par session en moyenne. Quand tu t'installes, c'est pas pour cinq minutes.`
|
||
]),
|
||
highlight: '🍿', subtext: 'Le confort avant tout.'
|
||
});
|
||
}
|
||
if (Array.isArray(weekday) && weekday.length > 0) {
|
||
const topDow = weekday.reduce((a, b) => (b.minutes > a.minutes ? b : a), weekday[0]);
|
||
if (topDow && topDow.minutes >= 120) {
|
||
const dayNames = ['', 'dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
|
||
const dayName = dayNames[topDow.dow];
|
||
funFacts.push({
|
||
title: `Le ${dayName}, c'est sacré`, subtitle: '',
|
||
text: pick('ff-dow', [
|
||
`Ton jour le plus chargé de l'année : le ${dayName}. ${formatDuration(topDow.minutes)} rien que sur ce créneau hebdo.`,
|
||
`S'il fallait te chercher un jour précis, ce serait le ${dayName} : ${formatDuration(topDow.minutes)} cumulées dessus cette année.`
|
||
]),
|
||
highlight: '📅', subtext: ''
|
||
});
|
||
}
|
||
}
|
||
if (totalActiveDays > 100) {
|
||
const activePct = Math.min(100, Math.round((totalActiveDays / 365) * 100));
|
||
funFacts.push({
|
||
title: `${totalActiveDays} jours actifs`, subtitle: '',
|
||
text: pick('ff-days', [
|
||
`T'as lancé Movix ${totalActiveDays} jours cette année — ${activePct}% de tes journées. La fidélité, la vraie.`,
|
||
`${totalActiveDays} jours avec au moins une session. Presque ${activePct}% de l'année passée ensemble.`
|
||
]),
|
||
highlight: '📆', subtext: ''
|
||
});
|
||
}
|
||
if (firstWatchData) {
|
||
const firstDate = new Date(firstWatchData.created_at);
|
||
const dayOfYear = Math.ceil((firstDate - new Date(firstDate.getFullYear(), 0, 1)) / (1000 * 60 * 60 * 24));
|
||
if (dayOfYear <= 3) {
|
||
funFacts.push({ title: 'Aucune minute à perdre', subtitle: '', text: `Ton premier visionnage de ${year} ? "${firstWatchData.content_title}", dès le ${dayOfYear === 1 ? '1er' : dayOfYear + 'e'} janvier. T'as pas traîné.`, highlight: '🎆', subtext: '' });
|
||
}
|
||
}
|
||
if (funFacts.length > 0) {
|
||
const picked = seededShuffle(funFacts, seed);
|
||
slides.push({ type: 'fun-fact', ...picked[0] });
|
||
if (picked.length > 1 && totalHours > 50) {
|
||
slides.push({ type: 'fun-fact', ...picked[1] });
|
||
}
|
||
}
|
||
|
||
// === 13. PERSONA — révélation climax (avant-dernier acte) ===
|
||
const typeLabel = { 'anime': 'les animes', 'movie': 'les films', 'tv': 'les séries', 'live-tv': 'la TV en direct' };
|
||
const vibeTemplates = [
|
||
{
|
||
condition: dominantPercent > 80,
|
||
texts: [
|
||
`${dominantPercent}% de ton temps sur ${typeLabel[dominantType.content_type]}. Tu sais ce que t'aimes, et tu assumes à fond.`,
|
||
`${typeLabel[dominantType.content_type]} à ${dominantPercent}% de ton temps : un choix clair, net et totalement assumé.`
|
||
]
|
||
},
|
||
{
|
||
condition: dominantPercent > 50,
|
||
texts: [
|
||
`Ta vibe ? Surtout ${typeLabel[dominantType.content_type]}, avec ce qu'il faut de variété.`,
|
||
`${typeLabel[dominantType.content_type]} en majorité, le reste en exploration. Le bon dosage.`
|
||
]
|
||
},
|
||
{
|
||
condition: true,
|
||
texts: [
|
||
`Toi, c'est un peu de tout. L'équilibre, le vrai.`,
|
||
`Films, séries, animes : tu refuses de choisir, et t'as bien raison.`
|
||
]
|
||
}
|
||
];
|
||
slides.push({
|
||
type: 'persona',
|
||
title: persona.title,
|
||
subtitle: persona.subtitle,
|
||
text: pick('persona', vibeTemplates.find(t => t.condition).texts),
|
||
highlight: persona.emoji,
|
||
subtext: persona.description
|
||
});
|
||
|
||
// === 14. DETAILED STATS ===
|
||
slides.push({ type: 'detailed-stats', title: 'Tes stats', subtitle: 'En détail', text: 'Le récap complet de ton année.', highlight: '📊', subtext: '' });
|
||
|
||
// === 15. CLOSING (rendu générique de fin côté front) ===
|
||
slides.push({
|
||
type: 'closing',
|
||
title: pick('closing-t', [`On remet ça en ${year + 1} ?`, `${year}, c'est dans la boîte.`]),
|
||
subtitle: pick('closing-st', ['Nous, on est partants.', 'Clap de fin.']),
|
||
text: pick('closing', [
|
||
`${totalDurationLabel}. ${uniqueTitles} titres. 1 seul toi. Merci d'avoir passé l'année sur Movix.`,
|
||
`${totalDurationLabel} de visionnage, ${uniqueTitles} titres traversés, et une année qui te ressemble. Merci d'avoir été là.`
|
||
]),
|
||
highlight: '💜',
|
||
subtext: pick('closing-s', ['Cette année était validée.', 'On garde ta place au chaud.', 'Même heure, même canapé ?'])
|
||
});
|
||
|
||
return slides;
|
||
}
|
||
|
||
|
||
module.exports = { router, initWrappedRoutes, initTables };
|