mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
1673 lines
70 KiB
JavaScript
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 };
|