MovixOpenSource/API/Mainapi/wrappedRoutes.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

1673 lines
70 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;
return {
title: data.title || data.name,
poster_path: data.poster_path,
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
};
}
/**
* 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 */ }
}
// Expensive SQL — runs once per 30 min then cached for all users/workers
const pctT0 = 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]);
_sqlTimings['percentile'] = Date.now() - pctT0;
console.log(`[Wrapped][PERF] 📊 Percentile: SQL in ${_sqlTimings['percentile']}ms (${allUsersStats.length} users)`);
redisSet(pctCacheKey, JSON.stringify(allUsersStats), PERCENTILE_CACHE_TTL);
const totalUsers = allUsersStats.length;
const userRank = allUsersStats.findIndex(u => u.user_id === userId) + 1;
return totalUsers > 1 ? Math.round(((totalUsers - userRank) / totalUsers) * 100) : 99;
}
// ── 1. Fire ALL queries in parallel (user-scoped + percentile) ──────────
_t.sqlStart = Date.now();
const [
[viewingStats],
[typeStats],
[topContentAll],
[monthlyStats],
[topPages],
[hourlyStats],
[dailyActivity],
[firstWatch],
[lastWatch],
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}
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}
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}
ORDER BY created_at DESC LIMIT 1
`, 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."
});
console.log(`[Wrapped][PERF] No data — total ${Date.now() - _t.start}ms`);
return res.json({ success: true, wrapped: null, message: "Pas encore de données pour cette année." });
}
// ── 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, genres: [], vote_average: null };
return { ...c, content_title: d.title || c.content_title, poster_path: d.poster_path || c.poster_path || null, genres: d.genres || [], vote_average: d.vote_average || 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;
const secondShow = enrichedTopContent[1] || null;
const secondShowTitle = secondShow ? secondShow.content_title : 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;
_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
});
const slides = generateSlides({
totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles,
topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, topShowType,
secondShowTitle, secondShow,
dominantType, dominantPercent,
peakMonth, lowestMonth, monthNames,
animePercent, moviePercent, tvPercent,
isExplorer, isLoyal, isBinger,
enrichedTopContent, typeStats, persona,
year,
percentile, peakHour, isNightOwl, isEarlyBird,
longestStreak, totalActiveDays,
firstWatchData, lastWatchData,
topGenres, avgSessionMinutes
});
_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,
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) }))
};
// ── 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 ===
// ============================================
/**
* 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
} = data;
// Personas par priorité (le premier qui matche gagne)
const personas = [
// --- PERSONAS EXTRÊMES (basés sur l'intensité) ---
{
condition: totalHours > 1000,
persona: {
id: 'legend',
title: 'La Légende Vivante',
emoji: '👑',
subtitle: 'Tu as littéralement vécu sur Movix cette année',
description: 'Plus de 1000 heures. On devrait te payer à ce stade.',
color: '#FFD700'
}
},
{
condition: totalHours > 500,
persona: {
id: 'marathon',
title: 'Le Marathonien Ultime',
emoji: '🏃‍♂️',
subtitle: 'Ton canapé a une empreinte permanente de toi',
description: 'Tu as transformé le binge-watching en sport olympique.',
color: '#FF6B35'
}
},
// --- NIGHT OWL ---
{
condition: isNightOwl && totalHours > 100,
persona: {
id: 'night-owl',
title: 'L\'Oiseau de Nuit',
emoji: '🦉',
subtitle: 'La nuit, tous les écrans sont gris... sauf le tien',
description: 'Tu fais tes meilleures sessions entre minuit et 5h.',
color: '#1A237E'
}
},
// --- PERSONAS PAR TYPE DOMINANT ---
{
condition: animePercent > 80,
persona: {
id: 'weeb-supreme',
title: 'Weeb Suprême',
emoji: '⛩️',
subtitle: 'Tu penses en sous-titres à ce stade',
description: 'Plus de 80% d\'anime. Tu es officiellement plus japonais que français.',
color: '#E91E63'
}
},
{
condition: animePercent > 50,
persona: {
id: 'otaku',
title: 'L\'Otaku Assumé',
emoji: '🍜',
subtitle: 'Ton cœur bat au rythme des openings',
description: 'Les animes ne sont pas une phase, c\'est un mode de vie.',
color: '#9C27B0'
}
},
{
condition: moviePercent > 70 && uniqueTitles > 50,
persona: {
id: 'critic',
title: 'Le Critique',
emoji: '🎬',
subtitle: 'Tu as un avis sur tout, et il est probablement juste',
description: 'Tu pourrais écrire pour les Cahiers du Cinéma.',
color: '#2196F3'
}
},
{
condition: moviePercent > 70,
persona: {
id: 'cinephile',
title: 'Le Cinéphile',
emoji: '🎥',
subtitle: 'Le 7ème 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" est ton mantra',
description: 'Tu ne 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: 'Tu connais plus de personnages fictifs que de vraies personnes',
description: 'Les séries sont ta deuxième famille.',
color: '#009688'
}
},
// --- PERSONAS PAR COMPORTEMENT ---
{
condition: isExplorer && uniqueTitles > 100,
persona: {
id: 'explorer-elite',
title: 'L\'Explorateur d\'Élite',
emoji: '🧭',
subtitle: 'Tu as vu des trucs dont personne n\'a entendu parler',
description: 'Plus de 100 titres ! Tu es la définition de la curiosité.',
color: '#FF9800'
}
},
{
condition: isExplorer,
persona: {
id: 'explorer',
title: 'L\'Explorateur',
emoji: '🔍',
subtitle: 'Toujours en quête de la prochaine pépite',
description: 'Tu préfères découvrir que revoir.',
color: '#FFC107'
}
},
{
condition: isLoyal && topShowHours > 50,
persona: {
id: 'superfan',
title: 'Le Superfan',
emoji: '💜',
subtitle: 'Tu as trouvé TON truc et tu t\'y tiens',
description: 'La loyauté, c\'est ta plus grande qualité.',
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 comme ça.',
color: '#795548'
}
}
];
// Chercher le premier persona qui matche
for (const p of personas) {
if (p.condition === true) {
return p.persona;
}
}
// Persona par défaut basé sur le type dominant
const defaultPersonas = {
'anime': {
id: 'anime-fan',
title: 'L\'Anime Fan',
emoji: '✨',
subtitle: 'Tu apprécies l\'art de l\'animation japonaise',
description: 'Un équilibre sain 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 font partie de 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: 'Tu restes connecté à l\'actualité',
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 avec des templates "à trous"
* Écrit comme un copywriter Spotify le ferait
*/
function generateSlides(data) {
const {
totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles,
topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, topShowType,
secondShowTitle, secondShow,
dominantType, dominantPercent,
peakMonth, lowestMonth, monthNames,
animePercent, moviePercent, tvPercent,
isExplorer, isLoyal, isBinger,
enrichedTopContent, typeStats, persona,
year,
// New data
percentile, peakHour, isNightOwl, isEarlyBird,
longestStreak, totalActiveDays,
firstWatchData, lastWatchData,
topGenres, avgSessionMinutes
} = data;
const slides = [];
// === SLIDE 1: INTRO / HOOK ===
const introTemplates = [
{
condition: totalHours > 200,
title: `${year}, c'était intense.`,
subtitle: 'Genre, vraiment intense.',
text: `Tu as passé ${totalDurationLabel} sur Movix. C'est ${totalDays} jours complets. On espère que t'avais des snacks.`,
highlight: `${totalDays} jours`
},
{
condition: totalHours > 100,
title: `${year} ? Tu l'as bien remplie.`,
subtitle: 'Et ton historique aussi.',
text: `${totalDurationLabel} de contenu. C'est plus que certains jobs à temps partiel.`,
highlight: `${formatDurationShort(totalMinutes)}`
},
{
condition: totalHours > 50,
title: `Pas mal, ${year}.`,
subtitle: 'Pas mal du tout.',
text: `${totalDurationLabel} de streaming. Tu sais ce que tu veux.`,
highlight: `${formatDurationShort(totalMinutes)}`
},
{
condition: true,
title: `${year} s'est bien passée.`,
subtitle: 'On a les preuves.',
text: `${totalDurationLabel} ensemble cette année. C'est un bon début.`,
highlight: `${formatDurationShort(totalMinutes)}`
}
];
slides.push({ type: 'intro', ...introTemplates.find(t => t.condition) });
// === SLIDE 2: TOP 1 ===
if (topShowTitle) {
const top1Templates = [
{
condition: topShowHours > 100,
title: `"${topShowTitle}"`,
subtitle: 'Ton obsession de l\'année',
text: `${topShowDurationLabel} dessus. À ce stade, tu pourrais écrire la suite toi-même.`,
highlight: `#1`,
subtext: 'C\'est un peu gênant, mais on adore.'
},
{
condition: topShowHours > 50,
title: `"${topShowTitle}"`,
subtitle: 'Ton coup de cœur absolu',
text: `${topShowDurationLabel}. Tu l'as regardé, rerererereregardé, et tu recommencerais.`,
highlight: `#1`,
subtext: 'Fan numéro 1 ? C\'est toi.'
},
{
condition: topShowHours > 20,
title: `"${topShowTitle}"`,
subtitle: 'Ta grande histoire d\'amour',
text: `${topShowDurationLabel} ensemble. C'est plus que certaines relations.`,
highlight: `#1`,
subtext: 'Et on ne juge pas.'
},
{
condition: true,
title: `"${topShowTitle}"`,
subtitle: 'Ton préféré de l\'année',
text: `${topShowDurationLabel} passées dessus. Un classique dans ton cœur.`,
highlight: `#1`,
subtext: ''
}
];
slides.push({ type: 'top1', ...top1Templates.find(t => t.condition) });
}
// === SLIDE 3: 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: 'Ceux qui ont marqué ton année',
text: top5Text,
highlight: `${uniqueTitles} titres au total`,
subtext: uniqueTitles > 50 ? 'Tu es incollable.' : ''
});
}
// === SLIDE 4: PERSONNALITÉ / VIBE ===
const typeLabel = {
'anime': 'les animes',
'movie': 'les films',
'tv': 'les séries',
'live-tv': 'la TV en direct'
};
const vibeTemplates = [
{
condition: dominantPercent > 80,
title: persona.title,
subtitle: persona.subtitle,
text: `${dominantPercent}% de ton temps sur ${typeLabel[dominantType.content_type]}. Tu sais ce que tu aimes, et tu assumes.`,
highlight: persona.emoji,
subtext: persona.description
},
{
condition: dominantPercent > 50,
title: persona.title,
subtitle: persona.subtitle,
text: `Ta vibe ? Principalement ${typeLabel[dominantType.content_type]}, avec une touche de variété.`,
highlight: persona.emoji,
subtext: persona.description
},
{
condition: true,
title: persona.title,
subtitle: persona.subtitle,
text: `Tu es éclectique. Un peu de tout, c'est ton style.`,
highlight: persona.emoji,
subtext: persona.description
}
];
slides.push({ type: 'persona', ...vibeTemplates.find(t => t.condition) });
// === SLIDE 5: MOIS LE PLUS ACTIF ===
const peakMonthMinutes = parseInt(peakMonth.duration);
const peakMonthHours = Math.round(peakMonthMinutes / 60);
const peakMonthDurationLabel = formatDuration(peakMonthMinutes);
const monthTemplates = [
{
condition: peakMonthHours > 50,
title: monthNames[peakMonth.month],
subtitle: 'Ton mois de folie',
text: `${peakMonthDurationLabel} en un seul mois. Il s'est passé quoi ? Rupture ? Vacances ? Les deux ?`,
highlight: `🔥`,
subtext: 'On ne juge pas, on constate.'
},
{
condition: peakMonthHours > 20,
title: monthNames[peakMonth.month],
subtitle: 'Ton mois le plus actif',
text: `${peakMonthDurationLabel}. Tu étais dans ta bulle, et c'était bien.`,
highlight: `📅`,
subtext: ''
},
{
condition: true,
title: monthNames[peakMonth.month],
subtitle: 'Là où tout s\'est joué',
text: `Ton pic de l'année. ${peakMonthDurationLabel} de pur bonheur.`,
highlight: ``,
subtext: ''
}
];
slides.push({ type: 'peak-month', ...monthTemplates.find(t => t.condition) });
// === SLIDE 6: TOP GENRES ===
if (topGenres && topGenres.length >= 2) {
const topGenreNames = topGenres.slice(0, 3).map(g => g.name).join(', ');
slides.push({
type: 'top-genres',
title: 'Tes genres préférés',
subtitle: topGenreNames,
text: topGenres.length >= 3
? `${topGenres[0].name} domine avec ${topGenres[0].percent}% de ton temps. Suivi par ${topGenres[1].name} et ${topGenres[2].name}.`
: `${topGenres[0].name} domine avec ${topGenres[0].percent}% de ton temps.`,
highlight: '🎭',
subtext: topGenres.length > 5 ? `Et aussi : ${topGenres.slice(5).map(g => g.name).join(', ')}` : ''
});
}
// === SLIDE 7: LISTENING CLOCK (Quand tu regardes) ===
const hourLabel = (h) => `${h}h`;
const clockTemplates = [
{
condition: isNightOwl,
title: 'Le mode nocturne',
subtitle: `Tu es le plus actif vers ${hourLabel(peakHour)}`,
text: 'Pendant que le monde dort, toi tu mates. Tes yeux rouges ne mentent pas.',
highlight: '🌙',
subtext: 'Les meilleures sessions, c\'est la nuit.'
},
{
condition: isEarlyBird,
title: 'Lève-tôt, regarde tôt',
subtitle: `Ton pic d'activité : ${hourLabel(peakHour)}`,
text: 'Tu lances un épisode avant même que le café soit prêt. Respect.',
highlight: '🌅',
subtext: ''
},
{
condition: true,
title: `${hourLabel(peakHour)}, ton heure de pointe`,
subtitle: 'Ton horloge de visionnage',
text: `C'est à ${hourLabel(peakHour)} que tu lances le plus souvent Movix. On connaît tes habitudes maintenant.`,
highlight: '⏰',
subtext: ''
}
];
slides.push({ type: 'listening-clock', ...clockTemplates.find(t => t.condition) });
// === SLIDE 8: STREAK ===
if (longestStreak >= 3) {
const streakTemplates = [
{
condition: longestStreak >= 30,
title: `${longestStreak} jours de suite`,
subtitle: 'Un mois complet non-stop',
text: `Ta plus longue série de visionnage : ${longestStreak} jours consécutifs. On appelle ça de la détermination.`,
highlight: '🔥',
subtext: `Sur ${totalActiveDays} jours actifs cette année.`
},
{
condition: longestStreak >= 14,
title: `${longestStreak} jours d'affilée`,
subtitle: 'Streak impressionnant',
text: `Deux semaines sans lâcher Movix. Tu as une discipline de fer (pour le streaming en tout cas).`,
highlight: '⚡',
subtext: `${totalActiveDays} jours d'activité au total.`
},
{
condition: longestStreak >= 7,
title: `${longestStreak} jours non-stop`,
subtitle: 'Une semaine de dédi',
text: `Ta meilleure série : ${longestStreak} jours sans interruption. C'est de la constance.`,
highlight: '💪',
subtext: ''
},
{
condition: true,
title: `${longestStreak} jours de streak`,
subtitle: 'Ta meilleure série',
text: `${longestStreak} jours de visionnage consécutifs. Pas mal comme streak.`,
highlight: '🎯',
subtext: ''
}
];
slides.push({ type: 'streak', ...streakTemplates.find(t => t.condition) });
}
// === SLIDE 9: FUN FACT ===
const funFacts = [];
if (totalHours > 24) {
const equivalent = Math.floor(totalHours / 2); // Un film moyen = 2h
funFacts.push({
title: 'En d\'autres termes...',
subtitle: '',
text: `Tu aurais pu regarder ${equivalent} films de suite. Ou dormir ${totalDays} jours. Tu as fait un choix.`,
highlight: '🤔',
subtext: 'Le bon choix.'
});
} else if (totalMinutes > 30) {
funFacts.push({
title: 'En d\'autres termes...',
subtitle: '',
text: `${totalDurationLabel} de streaming. C'est déjà un bon début pour cette année.`,
highlight: '🤔',
subtext: 'Et ce n\'est que le début.'
});
}
if (isExplorer && uniqueTitles > 30) {
funFacts.push({
title: 'L\'algorithme t\'adore',
subtitle: '',
text: `${uniqueTitles} titres différents. Tu rends notre job de recommandation vraiment difficile (et intéressant).`,
highlight: '🧠',
subtext: ''
});
}
if (isBinger && topShowTitle) {
const percentOfTotal = totalMinutes > 0 ? Math.round((topShowMinutes / totalMinutes) * 100) : 0;
funFacts.push({
title: 'Confession time',
subtitle: '',
text: `"${topShowTitle}" représente ${percentOfTotal}% de ton temps total. C'est de l'engagement.`,
highlight: '💍',
subtext: 'Ou de l\'obsession. Mais qui compte ?'
});
}
// Percentile fun fact
if (percentile >= 90) {
funFacts.push({
title: `Top ${100 - percentile}% des viewers`,
subtitle: 'Tu es dans l\'élite',
text: `Tu regardes plus que ${percentile}% des utilisateurs Movix. On devrait te donner un badge.`,
highlight: '🏆',
subtext: ''
});
}
// Session length fun fact
if (avgSessionMinutes > 90) {
funFacts.push({
title: 'Sessions marathon',
subtitle: '',
text: `En moyenne, tes sessions durent ${avgSessionMinutes} minutes. Tu ne fais pas les choses à moitié.`,
highlight: '🍿',
subtext: 'Netflix and actually chill.'
});
}
// First watch of the year fun fact
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: 'Pas de temps à perdre',
subtitle: '',
text: `Ton premier visionnage de ${year} ? "${firstWatchData.content_title}", dès le ${dayOfYear === 1 ? '1er' : dayOfYear + 'ème'} janvier. Tu n'as pas attendu longtemps.`,
highlight: '🎆',
subtext: ''
});
}
}
if (funFacts.length > 0) {
// Pick 1-2 fun facts
const shuffled = funFacts.sort(() => 0.5 - Math.random());
slides.push({ type: 'fun-fact', ...shuffled[0] });
if (shuffled.length > 1 && totalHours > 50) {
slides.push({ type: 'fun-fact', ...shuffled[1] });
}
}
// === SLIDE 10: DETAILED STATS ===
slides.push({
type: 'detailed-stats',
title: 'Tes Statistiques',
subtitle: 'En détail',
text: 'Le résumé complet de ton année.',
highlight: '📊',
subtext: ''
});
// === SLIDE 8: CLOSING ===
slides.push({
type: 'closing',
title: `C'était ${year}.`,
subtitle: 'Avec toi.',
text: `${totalDurationLabel}. ${uniqueTitles} titres. 1 seul toi. Merci d'avoir passé cette année sur Movix.`,
highlight: '💜',
subtext: 'À l\'année prochaine ?'
});
return slides;
}
module.exports = { router, initWrappedRoutes, initTables };