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

609 lines
16 KiB
JavaScript

/**
* Movix Top 10 - Public endpoint
* Returns the top 10 most watched movies and series by logged-in users
* Based on aggregated data from wrapped_viewing_data table
* Uses Redis for caching
*/
const express = require("express");
const router = express.Router();
const { fetchTmdbDetails } = require("./utils/tmdbCache");
const TMDB_API_URL = "https://api.themoviedb.org/3";
let pool = null;
let redis = null;
const CACHE_REFRESH = 1800; // 30 min before background refresh
const CACHE_TTL = 86400; // 24h Redis TTL
const CACHE_PREFIX = "top10:";
const LOCK_PREFIX = `${CACHE_PREFIX}lock:`;
const LOCK_TTL = 120; // seconds
const LOCK_WAIT_MS = 2500;
const LOCK_POLL_MS = 100;
// In-memory locks to avoid duplicate background refreshes.
const refreshLocks = new Set();
const TOP10_TYPE_CONFIG = {
movies: {
contentType: "movie",
minDuration: 1200,
tmdbType: "movie",
emptyLabel: "Film",
withEpisodes: false,
},
tv: {
contentType: "tv",
minDuration: 300,
tmdbType: "tv",
emptyLabel: "Serie",
withEpisodes: true,
},
anime: {
contentType: "anime",
minDuration: 300,
tmdbType: "anime",
emptyLabel: "Anime",
withEpisodes: true,
},
};
/**
* Initialize with MySQL pool and Redis instance
*/
function initTop10Routes(mysqlPool, redisInstance) {
pool = mysqlPool;
redis = redisInstance || null;
}
/**
* Redis cache helpers - stale-while-revalidate.
* Stored shape: { data, updatedAt }
*/
async function cacheGet(key) {
if (!redis) return null;
try {
const raw = await redis.get(`${CACHE_PREFIX}${key}`);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.warn("[Top10] Redis GET error:", err.message);
return null;
}
}
async function cacheSet(key, value) {
if (!redis) return;
try {
const wrapper = { data: value, updatedAt: Date.now() };
await redis.set(
`${CACHE_PREFIX}${key}`,
JSON.stringify(wrapper),
"EX",
CACHE_TTL,
);
} catch (err) {
console.warn("[Top10] Redis SET error:", err.message);
}
}
function isStale(wrapper) {
if (!wrapper || !wrapper.updatedAt) return true;
return Date.now() - wrapper.updatedAt > CACHE_REFRESH * 1000;
}
function getTop10TypeConfig(type) {
return type ? TOP10_TYPE_CONFIG[type] || null : null;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getLockStorageKey(key) {
return `${LOCK_PREFIX}${key}`;
}
function acquireLocalLock(lockKey) {
if (refreshLocks.has(lockKey)) {
return null;
}
refreshLocks.add(lockKey);
return { key: lockKey, local: true };
}
async function acquireDistributedLock(key) {
const lockKey = getLockStorageKey(key);
if (!redis) {
return acquireLocalLock(lockKey);
}
const owner = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
try {
const result = await redis.set(lockKey, owner, "EX", LOCK_TTL, "NX");
if (result === "OK") {
return { key: lockKey, owner, local: false };
}
return null;
} catch (err) {
console.warn("[Top10] Redis LOCK error:", err.message);
return acquireLocalLock(lockKey);
}
}
async function releaseDistributedLock(lock) {
if (!lock) return;
if (lock.local) {
refreshLocks.delete(lock.key);
return;
}
if (!redis) return;
try {
await redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1,
lock.key,
lock.owner,
);
} catch (err) {
console.warn("[Top10] Redis UNLOCK error:", err.message);
}
}
async function waitForCachedPayload(key, timeoutMs = LOCK_WAIT_MS) {
if (!redis) return null;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(LOCK_POLL_MS);
const wrapper = await cacheGet(key);
if (wrapper && wrapper.data) {
return wrapper.data;
}
}
return null;
}
async function buildWithCacheLock(key, builder) {
const lock = await acquireDistributedLock(key);
if (!lock) {
const cachedPayload = await waitForCachedPayload(key);
if (cachedPayload) {
return cachedPayload;
}
}
try {
const result = await builder();
await cacheSet(key, result);
return result;
} finally {
if (lock) {
await releaseDistributedLock(lock);
}
}
}
function scheduleBackgroundRefresh(key, refreshTask) {
void (async () => {
const lock = await acquireDistributedLock(key);
if (!lock) return;
try {
await refreshTask();
} finally {
await releaseDistributedLock(lock);
}
})();
}
/**
* Fetch TMDB details for enrichment via shared Redis cache.
*/
async function fetchTMDBDetails(contentId, contentType) {
if (contentType === "live-tv") return null;
const mediaType = contentType === "anime" ? "tv" : contentType;
const data = await fetchTmdbDetails(
TMDB_API_URL,
process.env.TMDB_API_KEY,
contentId,
mediaType,
"fr-FR",
);
if (!data) return null;
return {
title: data.title || data.name,
poster_path: data.poster_path,
backdrop_path: data.backdrop_path,
overview: data.overview,
vote_average: data.vote_average || null,
genres: (data.genres || []).map((genre) =>
typeof genre === "string" ? genre : genre.name,
),
release_date: data.release_date || data.first_air_date || null,
runtime: data.runtime || data.episode_run_time?.[0] || null,
};
}
async function getTop10Response(category) {
const wrapper = await cacheGet(category);
if (wrapper && wrapper.data) {
if (isStale(wrapper)) {
scheduleBackgroundRefresh(category, () => refreshTop10(category));
}
return wrapper.data;
}
const builder = builders[category];
if (!builder) {
throw new Error(`Unknown Top10 category: ${category}`);
}
return buildWithCacheLock(category, builder);
}
async function buildStatsResult(requestedType, typeConfig) {
let statsQuery = `
SELECT
COUNT(DISTINCT user_id) AS total_active_users,
COUNT(DISTINCT content_id) AS total_unique_content,
ROUND(SUM(watch_duration) / 3600, 0) AS total_hours_watched,
COUNT(*) AS total_sessions,
ROUND(AVG(watch_duration) / 60, 0) AS avg_session_minutes,
MIN(created_at) AS data_from,
MAX(created_at) AS data_to
FROM wrapped_viewing_data
WHERE watch_duration >= ?
`;
let statsParams = [300];
if (typeConfig) {
statsQuery = `
SELECT
COUNT(DISTINCT user_id) AS total_active_users,
COUNT(DISTINCT content_id) AS total_unique_content,
ROUND(SUM(watch_duration) / 3600, 0) AS total_hours_watched,
COUNT(*) AS total_sessions,
ROUND(AVG(watch_duration) / 60, 0) AS avg_session_minutes,
MIN(created_at) AS data_from,
MAX(created_at) AS data_to
FROM wrapped_viewing_data
WHERE content_type = ?
AND watch_duration >= ?
`;
statsParams = [typeConfig.contentType, typeConfig.minDuration];
}
const [stats] = await pool.execute(statsQuery, statsParams);
const row = stats[0] || {};
return {
success: true,
type: requestedType || "global",
stats: {
totalActiveUsers: parseInt(row.total_active_users) || 0,
totalUniqueContent: parseInt(row.total_unique_content) || 0,
totalHoursWatched: parseInt(row.total_hours_watched) || 0,
totalSessions: parseInt(row.total_sessions) || 0,
avgSessionMinutes: parseInt(row.avg_session_minutes) || 0,
dataFrom: row.data_from ? new Date(row.data_from).toISOString() : null,
dataTo: row.data_to ? new Date(row.data_to).toISOString() : null,
},
updatedAt: new Date().toISOString(),
};
}
async function getStatsResponse(requestedType, typeConfig) {
const cacheKey = requestedType ? `stats:${requestedType}` : "stats";
const wrapper = await cacheGet(cacheKey);
if (wrapper && wrapper.data) {
if (isStale(wrapper)) {
scheduleBackgroundRefresh(cacheKey, () =>
refreshStats(requestedType, typeConfig),
);
}
return wrapper.data;
}
return buildWithCacheLock(cacheKey, () =>
buildStatsResult(requestedType, typeConfig),
);
}
/**
* GET /api/top10/movies
* Public - no auth required
*/
router.get("/movies", async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const result = await getTop10Response("movies");
res.json(result);
} catch (error) {
console.error("[Top10] Error fetching movies:", error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
/**
* GET /api/top10/tv
* Public - no auth required
*/
router.get("/tv", async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const result = await getTop10Response("tv");
res.json(result);
} catch (error) {
console.error("[Top10] Error fetching TV:", error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
/**
* GET /api/top10/anime
* Public - no auth required
*/
router.get("/anime", async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const result = await getTop10Response("anime");
res.json(result);
} catch (error) {
console.error("[Top10] Error fetching anime:", error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
/**
* GET /api/top10/overview?type=movies|tv|anime
* Public - returns ranking + stats for one category
*/
router.get("/overview", async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const requestedType =
typeof req.query.type === "string"
? req.query.type.toLowerCase()
: "movies";
const typeConfig = getTop10TypeConfig(requestedType);
if (!typeConfig) {
return res.status(400).json({
success: false,
error: "Invalid type. Allowed values: movies, tv, anime",
});
}
const [top10Result, statsResult] = await Promise.allSettled([
getTop10Response(requestedType),
getStatsResponse(requestedType, typeConfig),
]);
if (top10Result.status !== "fulfilled") {
throw top10Result.reason;
}
const top10Payload = top10Result.value;
const statsPayload =
statsResult.status === "fulfilled" ? statsResult.value : null;
res.json({
success: true,
type: requestedType,
top10: top10Payload.top10,
stats: statsPayload?.stats || null,
updatedAt: top10Payload.updatedAt,
statsUpdatedAt: statsPayload?.updatedAt || null,
});
} catch (error) {
console.error("[Top10] Error fetching overview:", error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
/**
* GET /api/top10/stats
* Public - global platform stats
* Optional query param: type=movies|tv|anime
*/
router.get("/stats", async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const requestedType =
typeof req.query.type === "string" ? req.query.type.toLowerCase() : null;
const typeConfig = getTop10TypeConfig(requestedType);
if (requestedType && !typeConfig) {
return res.status(400).json({
success: false,
error: "Invalid type. Allowed values: movies, tv, anime",
});
}
const result = await getStatsResponse(requestedType, typeConfig);
res.json(result);
} catch (error) {
console.error("[Top10] Error fetching stats:", error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ---------------------------------------------------------------------------
// Top 10 builders
// ---------------------------------------------------------------------------
async function buildTop10Movies() {
return buildTop10ByCategory("movies");
}
async function buildTop10Tv() {
return buildTop10ByCategory("tv");
}
async function buildTop10Anime() {
return buildTop10ByCategory("anime");
}
async function loadEpisodesWatchedMap(config, contentIds) {
if (!config.withEpisodes || contentIds.length === 0) {
return new Map();
}
const placeholders = contentIds.map(() => "?").join(", ");
const [rows] = await pool.execute(
`
SELECT
content_id,
COUNT(DISTINCT CONCAT(IFNULL(season_number, ''), '-', IFNULL(episode_number, ''))) AS episodes_watched
FROM wrapped_viewing_data
WHERE content_type = ?
AND watch_duration >= ?
AND content_id IN (${placeholders})
GROUP BY content_id
`,
[config.contentType, config.minDuration, ...contentIds],
);
return new Map(
rows.map((row) => [
String(row.content_id),
parseInt(row.episodes_watched) || 0,
]),
);
}
async function buildTop10ByCategory(category) {
const config = getTop10TypeConfig(category);
if (!config) {
throw new Error(`Unknown Top10 category: ${category}`);
}
const [rows] = await pool.execute(
`
SELECT
content_id,
MAX(content_title) AS content_title,
COUNT(DISTINCT user_id) AS unique_viewers,
ROUND(SUM(watch_duration) / 3600, 1) AS total_hours,
COUNT(*) AS total_sessions,
ROUND(AVG(watch_duration) / 60, 0) AS avg_session_minutes
FROM wrapped_viewing_data
WHERE content_type = ?
AND watch_duration >= ?
GROUP BY content_id
ORDER BY unique_viewers DESC, total_hours DESC
LIMIT 10
`,
[config.contentType, config.minDuration],
);
const episodesByContentId = await loadEpisodesWatchedMap(
config,
rows.map((row) => row.content_id),
);
const enriched = await Promise.all(
rows.map(async (row, index) => {
const tmdb = await fetchTMDBDetails(row.content_id, config.tmdbType);
return {
rank: index + 1,
contentId: row.content_id,
title:
tmdb?.title ||
row.content_title ||
`${config.emptyLabel} #${row.content_id}`,
posterPath: tmdb?.poster_path || null,
backdropPath: tmdb?.backdrop_path || null,
overview: tmdb?.overview || null,
voteAverage: tmdb?.vote_average || null,
genres: tmdb?.genres || [],
releaseDate: tmdb?.release_date || null,
uniqueViewers: parseInt(row.unique_viewers) || 0,
totalHours: parseFloat(row.total_hours) || 0,
totalSessions: parseInt(row.total_sessions) || 0,
avgSessionMinutes: parseInt(row.avg_session_minutes) || 0,
episodesWatched: config.withEpisodes
? episodesByContentId.get(String(row.content_id)) || 0
: undefined,
};
}),
);
return {
success: true,
type: category,
top10: enriched,
updatedAt: new Date().toISOString(),
};
}
// ---------------------------------------------------------------------------
// Background refresh (stale-while-revalidate)
// ---------------------------------------------------------------------------
const builders = {
movies: buildTop10Movies,
tv: buildTop10Tv,
anime: buildTop10Anime,
};
async function refreshTop10(category) {
try {
const builder = builders[category];
if (!builder) return;
const result = await builder();
await cacheSet(category, result);
console.log(`[Top10] Cache ${category} refreshed in background`);
} catch (err) {
console.error(`[Top10] Refresh error for ${category}:`, err.message);
}
}
async function refreshStats(requestedType, typeConfig) {
try {
const cacheKey = requestedType ? `stats:${requestedType}` : "stats";
const result = await buildStatsResult(requestedType, typeConfig);
await cacheSet(cacheKey, result);
console.log(`[Top10] Cache stats ${cacheKey} refreshed in background`);
} catch (err) {
console.error("[Top10] Refresh error for stats:", err.message);
}
}
module.exports = { router, initTop10Routes };