MovixOpenSource/API/Mainapi/recommendationRoutes.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

724 lines
26 KiB
JavaScript

/**
* Movix Personalized Recommendations
* Builds user taste profiles from viewing history and generates
* scored recommendations via TMDB discovery + similarity APIs.
* Includes collaborative filtering: compares user profiles to find
* content loved by users with similar tastes.
*
* Exports: { router, initRecommendationRoutes }
*/
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const axios = require('axios');
const { fetchTmdbDetails } = require('./utils/tmdbCache');
// ─── Configuration ──────────────────────────────────────────────────────────
const TMDB_API_URL = 'https://api.themoviedb.org/3';
const JWT_SECRET = process.env.JWT_SECRET;
const TMDB_API_KEY = process.env.TMDB_API_KEY;
// Redis TTLs (seconds)
const PROFILE_CACHE_TTL = 6 * 60 * 60; // 6 hours
const RECO_CACHE_TTL = 60 * 60; // 1 hour
const COLLAB_CACHE_TTL = 3 * 60 * 60; // 3 hours
// In-memory cache for TMDB endpoints NOT covered by tmdbCache.js
// (recommendations, similar, discover, trending, genre lists, keywords, credits)
const tmdbMemCache = new Map();
const TMDB_MEM_TTL = 15 * 60 * 1000; // 15 minutes in ms
// Set by initRecommendationRoutes()
let pool = null;
let redis = null;
/**
* Initialize the router with MySQL pool and Redis client.
*/
function initRecommendationRoutes(mysqlPool, redisClient) {
pool = mysqlPool;
redis = redisClient || null;
}
// ─── Redis helpers ──────────────────────────────────────────────────────────
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 */ }
}
// ─── JWT middleware ─────────────────────────────────────────────────────────
const verifyToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
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 = {
userId: decoded.sub || decoded.userId,
userType: decoded.userType,
sessionId: decoded.sessionId
};
next();
} catch (error) {
return res.status(403).json({ success: false, error: 'Invalid token.' });
}
};
// ─── TMDB helpers ──────────────────────────────────────────────────────────
/**
* Fetch TMDB details using the shared Redis cache (tmdbCache.js).
* This reuses the same Redis keys as all other routes.
*/
async function fetchDetails(id, type, language) {
return fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, id, type, language);
}
/**
* Fetch TMDB endpoints not covered by tmdbCache.js (recommendations, similar,
* discover, trending, genre lists, keywords, credits).
* Uses in-memory cache (15min) to avoid hammering TMDB.
*/
async function tmdbFetch(path, params = {}) {
const cacheKey = `${path}?${Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&')}`;
// In-memory cache
const mem = tmdbMemCache.get(cacheKey);
if (mem && Date.now() - mem.ts < TMDB_MEM_TTL) {
return mem.data;
}
try {
const response = await axios.get(`${TMDB_API_URL}${path}`, {
params: { api_key: TMDB_API_KEY, ...params },
timeout: 10000
});
const data = response.data || null;
if (data) {
tmdbMemCache.set(cacheKey, { data, ts: Date.now() });
}
return data;
} catch {
return null;
}
}
// Periodically prune stale in-memory entries (every 5 min)
setInterval(() => {
const now = Date.now();
for (const [key, val] of tmdbMemCache) {
if (now - val.ts > TMDB_MEM_TTL) tmdbMemCache.delete(key);
}
}, 5 * 60 * 1000).unref();
// ─── Profile Builder ────────────────────────────────────────────────────────
/**
* Build a user taste profile from their viewing history.
*/
async function buildUserProfile(userId, profileId, language) {
// Check Redis cache first
const rKey = `profile:${userId}:${profileId}`;
const cached = await redisGet(rKey);
if (cached) {
try { return JSON.parse(cached); } catch { /* rebuild */ }
}
// Top 30 content by total watch duration
const [rows] = await pool.execute(
`SELECT content_id, content_type, content_title,
SUM(watch_duration) AS total_duration
FROM wrapped_viewing_data
WHERE user_id = ? AND profile_id = ?
GROUP BY content_id, content_type, content_title
ORDER BY total_duration DESC
LIMIT 30`,
[userId, profileId]
);
if (!rows || rows.length === 0) {
return null;
}
const maxDuration = Number(rows[0].total_duration) || 1;
// Fetch TMDB details via shared Redis cache + keywords/credits via tmdbFetch
const enriched = await Promise.all(rows.map(async (row) => {
const type = (row.content_type === 'anime') ? 'tv' : row.content_type;
if (type !== 'movie' && type !== 'tv') return null;
const id = row.content_id;
const [details, keywords, credits] = await Promise.all([
fetchDetails(id, type, language), // uses shared Redis cache
tmdbFetch(`/${type}/${id}/keywords`), // in-memory cache
tmdbFetch(`/${type}/${id}/credits`) // in-memory cache
]);
if (!details) return null;
return {
id,
type,
title: row.content_title || details.title || details.name,
poster_path: details.poster_path,
totalDuration: Number(row.total_duration),
details,
keywords,
credits,
originalType: row.content_type
};
}));
// Build weighted preference maps
const genres = {};
const keywords = {};
const people = {};
const decades = {};
const formats = { movie: 0, tv: 0 };
for (const item of enriched) {
if (!item) continue;
const normDuration = item.totalDuration / maxDuration;
// Completion weight
const runtimeMin = item.details.runtime
|| (item.details.episode_run_time && item.details.episode_run_time[0])
|| 90;
const runtimeSec = runtimeMin * 60;
const completionRatio = Math.min(item.totalDuration / runtimeSec, 1);
let completionWeight;
if (completionRatio >= 0.9) completionWeight = 3;
else if (completionRatio >= 0.5) completionWeight = 1.5;
else if (completionRatio >= 0.2) completionWeight = 1;
else completionWeight = 0.3;
const weight = normDuration * completionWeight;
// Genres
if (item.details.genres) {
for (const g of item.details.genres) {
genres[g.id] = (genres[g.id] || 0) + weight;
}
}
// Keywords
const kwList = item.keywords?.keywords || item.keywords?.results || [];
for (const kw of kwList) {
keywords[kw.id] = (keywords[kw.id] || 0) + weight;
}
// People: top 5 cast + director
if (item.credits) {
const topCast = (item.credits.cast || []).slice(0, 5);
for (const actor of topCast) {
if (!people[actor.id]) people[actor.id] = { name: actor.name, score: 0 };
people[actor.id].score += weight;
}
const directors = (item.credits.crew || []).filter(c => c.job === 'Director');
for (const dir of directors) {
if (!people[dir.id]) people[dir.id] = { name: dir.name, score: 0 };
people[dir.id].score += weight * 1.5;
}
}
// Decade
const dateStr = item.details.release_date || item.details.first_air_date;
if (dateStr) {
const year = parseInt(dateStr.substring(0, 4));
if (!isNaN(year)) {
const decade = `${Math.floor(year / 10) * 10}s`;
decades[decade] = (decades[decade] || 0) + weight;
}
}
// Format
const fmt = (item.originalType === 'movie') ? 'movie' : 'tv';
formats[fmt] += weight;
}
const profile = {
genres,
keywords,
people,
decades,
formats,
topContent: enriched.filter(Boolean).slice(0, 10).map(c => ({
id: c.id, type: c.type, title: c.title, poster_path: c.poster_path
})),
builtAt: Date.now()
};
// Cache in Redis
await redisSet(rKey, JSON.stringify(profile), PROFILE_CACHE_TTL);
return profile;
}
// ─── Collaborative Filtering ───────────────────────────────────────────────
/**
* Find content loved by users with similar taste profiles.
* Compares genre vectors between the current user and all other users
* who have viewing data, then recommends content those similar users
* watched but the current user hasn't.
*/
async function getCollaborativeRecommendations(userId, profileId, profile, language) {
const cacheKey = `collab:${userId}:${profileId}`;
const cached = await redisGet(cacheKey);
if (cached) {
try { return JSON.parse(cached); } catch { /* rebuild */ }
}
// Get all other users with enough viewing data
const [otherUsers] = await pool.execute(
`SELECT user_id, profile_id,
COUNT(DISTINCT content_id) as content_count
FROM wrapped_viewing_data
WHERE NOT (user_id = ? AND profile_id = ?)
GROUP BY user_id, profile_id
HAVING content_count >= 5
LIMIT 100`,
[userId, profileId]
);
if (otherUsers.length === 0) {
await redisSet(cacheKey, JSON.stringify([]), COLLAB_CACHE_TTL);
return [];
}
// Get current user's watched content
const [watchedRows] = await pool.execute(
`SELECT DISTINCT content_id, content_type FROM wrapped_viewing_data
WHERE user_id = ? AND profile_id = ?`,
[userId, profileId]
);
const myWatchedIds = new Set(watchedRows.map(r => String(r.content_id)));
// Build a simple genre vector for the current user (normalized)
const myGenreVec = normalizeVector(profile.genres);
// For each other user, compute similarity and collect their top content
const similarUsers = [];
for (const other of otherUsers) {
// Get that user's top content with genres
const [otherContent] = await pool.execute(
`SELECT content_id, content_type, content_title,
SUM(watch_duration) AS total_duration
FROM wrapped_viewing_data
WHERE user_id = ? AND profile_id = ?
GROUP BY content_id, content_type, content_title
ORDER BY total_duration DESC
LIMIT 15`,
[other.user_id, other.profile_id]
);
if (otherContent.length === 0) continue;
// Build that user's genre vector from TMDB details
const otherGenres = {};
for (const item of otherContent) {
const type = item.content_type === 'anime' ? 'tv' : item.content_type;
if (type !== 'movie' && type !== 'tv') continue;
const details = await fetchDetails(item.content_id, type, language);
if (details?.genres) {
for (const g of details.genres) {
otherGenres[g.id] = (otherGenres[g.id] || 0) + Number(item.total_duration);
}
}
}
const otherGenreVec = normalizeVector(otherGenres);
const similarity = cosineSimilarity(myGenreVec, otherGenreVec);
if (similarity > 0.3) { // Only consider users with >30% genre overlap
similarUsers.push({
similarity,
content: otherContent.filter(c => !myWatchedIds.has(String(c.content_id)))
});
}
}
// Sort by similarity, take top 10 similar users
similarUsers.sort((a, b) => b.similarity - a.similarity);
const topSimilar = similarUsers.slice(0, 10);
// Collect content from similar users, weighted by similarity and watch duration
const collabCandidates = new Map(); // contentId -> { score, item }
for (const user of topSimilar) {
for (const item of user.content) {
const key = String(item.content_id);
if (myWatchedIds.has(key)) continue;
const existing = collabCandidates.get(key);
const score = user.similarity * Math.log10(Number(item.total_duration) + 1);
if (!existing || existing.score < score) {
collabCandidates.set(key, {
score,
contentId: item.content_id,
contentType: item.content_type,
contentTitle: item.content_title
});
}
}
}
// Get top candidates and enrich with TMDB data
const sorted = [...collabCandidates.values()]
.sort((a, b) => b.score - a.score)
.slice(0, 15);
const enriched = await Promise.all(sorted.map(async (item) => {
const type = item.contentType === 'anime' ? 'tv' : item.contentType;
if (type !== 'movie' && type !== 'tv') return null;
const details = await fetchDetails(item.contentId, type, language);
if (!details || !details.poster_path) return null;
return {
id: Number(item.contentId),
title: details.title || details.name,
originalTitle: details.original_title || details.original_name,
mediaType: type,
posterPath: details.poster_path,
backdropPath: details.backdrop_path,
overview: details.overview,
voteAverage: details.vote_average,
releaseDate: details.release_date || details.first_air_date,
genreIds: (details.genres || []).map(g => g.id),
popularity: details.popularity,
score: Math.round(item.score * 1000) / 1000
};
}));
const result = enriched.filter(Boolean);
await redisSet(cacheKey, JSON.stringify(result), COLLAB_CACHE_TTL);
return result;
}
/**
* Normalize an object { key: value } into a unit vector.
*/
function normalizeVector(obj) {
const values = Object.values(obj);
const magnitude = Math.sqrt(values.reduce((sum, v) => sum + v * v, 0)) || 1;
const result = {};
for (const [k, v] of Object.entries(obj)) {
result[k] = v / magnitude;
}
return result;
}
/**
* Cosine similarity between two sparse vectors (objects).
*/
function cosineSimilarity(a, b) {
let dotProduct = 0;
for (const key of Object.keys(a)) {
if (b[key]) dotProduct += a[key] * b[key];
}
return dotProduct; // already normalized
}
// ─── Candidate Scoring ──────────────────────────────────────────────────────
function scoreCandidate(candidate, profile) {
// Genre affinity
let genreScore = 0;
const maxGenre = Math.max(...Object.values(profile.genres), 1);
if (candidate.genre_ids) {
for (const gid of candidate.genre_ids) {
genreScore += (profile.genres[gid] || 0) / maxGenre;
}
genreScore = candidate.genre_ids.length > 0
? Math.min(genreScore / candidate.genre_ids.length, 1)
: 0;
}
// Keyword affinity
let keywordScore = 0;
if (candidate._keywords && candidate._keywords.length > 0) {
const maxKw = Math.max(...Object.values(profile.keywords), 1);
for (const kw of candidate._keywords) {
keywordScore += (profile.keywords[kw.id] || 0) / maxKw;
}
keywordScore = Math.min(keywordScore / candidate._keywords.length, 1);
}
// People affinity
let peopleScore = 0;
if (candidate._credits) {
const maxPeople = Math.max(...Object.values(profile.people).map(p => p.score), 1);
const relevantPeople = [
...(candidate._credits.cast || []).slice(0, 5),
...(candidate._credits.crew || []).filter(c => c.job === 'Director')
];
for (const person of relevantPeople) {
if (profile.people[person.id]) {
peopleScore += profile.people[person.id].score / maxPeople;
}
}
peopleScore = relevantPeople.length > 0
? Math.min(peopleScore / Math.min(relevantPeople.length, 3), 1)
: 0;
}
// Popularity normalized
const popularityNorm = candidate.popularity
? Math.min(Math.log10(candidate.popularity + 1) / 3, 1)
: 0;
// Recency score
const dateStr = candidate.release_date || candidate.first_air_date;
let recencyScore = 0.5;
if (dateStr) {
const year = parseInt(dateStr.substring(0, 4));
const currentYear = new Date().getFullYear();
recencyScore = Math.max(1 - ((currentYear - year) / 30), 0);
}
// Format match
const candidateFormat = candidate.media_type || (candidate.title ? 'movie' : 'tv');
const totalFormat = profile.formats.movie + profile.formats.tv || 1;
const formatMatch = (profile.formats[candidateFormat] || 0) / totalFormat;
return (genreScore * 0.35) +
(keywordScore * 0.25) +
(peopleScore * 0.15) +
(popularityNorm * 0.10) +
(recencyScore * 0.10) +
(formatMatch * 0.05);
}
// ─── Recommendation Generator ───────────────────────────────────────────────
async function generateRecommendations(userId, profileId, language) {
const profile = await buildUserProfile(userId, profileId, language);
if (!profile) {
return {
becauseYouWatched: [],
topGenres: [],
trendingForYou: [],
usersAlsoWatched: [],
profileSummary: null
};
}
// Set of already watched content IDs
const [watchedRows] = await pool.execute(
`SELECT DISTINCT content_id FROM wrapped_viewing_data
WHERE user_id = ? AND profile_id = ?`,
[userId, profileId]
);
const watchedIds = new Set(watchedRows.map(r => String(r.content_id)));
// ─── Fetch candidates in parallel ───────────────────────────────────
const topContent = profile.topContent.slice(0, 5);
// 1. "Because you watched" — TMDB recommendations + similar
const becauseYouWatchedRaw = await Promise.all(
topContent.map(async (item) => {
const [recs, similar] = await Promise.all([
tmdbFetch(`/${item.type}/${item.id}/recommendations`, { language, page: 1 }),
tmdbFetch(`/${item.type}/${item.id}/similar`, { language, page: 1 })
]);
const candidates = [
...((recs?.results) || []),
...((similar?.results) || [])
].map(c => ({ ...c, media_type: c.media_type || item.type }));
return { source: item, candidates };
})
);
// 2. Top genres — TMDB discover
const sortedGenres = Object.entries(profile.genres)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const topGenresRaw = await Promise.all(
sortedGenres.map(async ([genreId]) => {
const [movieDiscover, tvDiscover] = await Promise.all([
tmdbFetch('/discover/movie', { language, with_genres: genreId, sort_by: 'popularity.desc', page: 1 }),
tmdbFetch('/discover/tv', { language, with_genres: genreId, sort_by: 'popularity.desc', page: 1 })
]);
const candidates = [
...((movieDiscover?.results) || []).map(c => ({ ...c, media_type: 'movie' })),
...((tvDiscover?.results) || []).map(c => ({ ...c, media_type: 'tv' }))
];
return { genreId, candidates };
})
);
// 3. Trending
const trendingData = await tmdbFetch('/trending/all/week', { language });
const trendingCandidates = (trendingData?.results || []).map(c => ({
...c, media_type: c.media_type || 'movie'
}));
// 4. Collaborative filtering (in parallel with the above processing)
const collabPromise = getCollaborativeRecommendations(userId, profileId, profile, language);
// ─── Score, deduplicate, exclude watched ────────────────────────────
const seen = new Set();
function processCandidate(c) {
const cid = `${c.media_type || 'movie'}-${c.id}`;
if (seen.has(cid) || watchedIds.has(String(c.id))) return null;
seen.add(cid);
const score = scoreCandidate(c, profile);
return { ...c, _score: score };
}
// "becauseYouWatched" groups
const becauseYouWatched = [];
for (const group of becauseYouWatchedRaw) {
const scored = group.candidates
.map(processCandidate)
.filter(Boolean)
.sort((a, b) => b._score - a._score)
.slice(0, 12);
if (scored.length >= 3) {
becauseYouWatched.push({
title: group.source.title,
sourceId: group.source.id,
sourceType: group.source.type,
poster_path: group.source.poster_path,
items: scored.map(formatResult)
});
}
if (becauseYouWatched.length >= 3) break;
}
// "topGenres" groups
const topGenres = [];
for (const group of topGenresRaw) {
const scored = group.candidates
.map(processCandidate)
.filter(Boolean)
.sort((a, b) => b._score - a._score)
.slice(0, 12);
if (scored.length >= 3) {
topGenres.push({
genreId: parseInt(group.genreId),
items: scored.map(formatResult)
});
}
if (topGenres.length >= 3) break;
}
// Resolve genre names
if (topGenres.length > 0) {
const [movieGenres, tvGenres] = await Promise.all([
tmdbFetch('/genre/movie/list', { language }),
tmdbFetch('/genre/tv/list', { language })
]);
const genreMap = {};
for (const g of (movieGenres?.genres || [])) genreMap[g.id] = g.name;
for (const g of (tvGenres?.genres || [])) genreMap[g.id] = g.name;
for (const group of topGenres) {
group.genreName = genreMap[group.genreId] || `Genre ${group.genreId}`;
}
}
// "trendingForYou"
const trendingForYou = trendingCandidates
.filter(c => c.media_type === 'movie' || c.media_type === 'tv')
.map(processCandidate)
.filter(Boolean)
.sort((a, b) => b._score - a._score)
.slice(0, 15)
.map(formatResult);
// "usersAlsoWatched" — collaborative filtering results
const usersAlsoWatched = await collabPromise;
// Profile summary
const profileSummary = {
topGenreIds: sortedGenres.map(([id]) => parseInt(id)),
preferredFormat: profile.formats.movie >= profile.formats.tv ? 'movie' : 'tv',
topDecade: Object.entries(profile.decades).sort((a, b) => b[1] - a[1])[0]?.[0] || null,
contentAnalyzed: profile.topContent.length,
builtAt: profile.builtAt
};
return { becauseYouWatched, topGenres, trendingForYou, usersAlsoWatched, profileSummary };
}
/**
* Format a scored candidate into the API response shape.
*/
function formatResult(item) {
return {
id: item.id,
title: item.title || item.name,
originalTitle: item.original_title || item.original_name,
mediaType: item.media_type || 'movie',
posterPath: item.poster_path,
backdropPath: item.backdrop_path,
overview: item.overview,
voteAverage: item.vote_average,
releaseDate: item.release_date || item.first_air_date,
genreIds: item.genre_ids,
popularity: item.popularity,
score: Math.round((item._score || 0) * 1000) / 1000
};
}
// ─── Route ──────────────────────────────────────────────────────────────────
router.get('/personalized', verifyToken, async (req, res) => {
try {
const userId = req.user.userId;
const profileId = req.query.profileId;
const language = req.query.language || 'fr-FR';
if (!profileId) {
return res.status(400).json({ success: false, error: 'profileId is required.' });
}
// Check Redis cache
const cacheKey = `reco:${userId}:${profileId}`;
const cached = await redisGet(cacheKey);
if (cached) {
try {
return res.json({ success: true, ...JSON.parse(cached) });
} catch { /* rebuild */ }
}
const result = await generateRecommendations(userId, profileId, language);
await redisSet(cacheKey, JSON.stringify(result), RECO_CACHE_TTL);
return res.json({ success: true, ...result });
} catch (error) {
console.error('[Recommendations] Error:', error);
return res.status(500).json({ success: false, error: 'Internal server error.' });
}
});
// ─── Exports ────────────────────────────────────────────────────────────────
module.exports = { router, initRecommendationRoutes };