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