MovixOpenSource/API/Mainapi/top10Routes.js
Movix 3a7ab5ad42 feat: synchronise le repo privé — Cast, sources, API et mobile 2.5.7
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour
regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV,
le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de
l’application mobile.

Lecteur, sources et Live TV

- Refonte du lecteur HLS avec sélection et mémorisation de la qualité,
  détection des résolutions, scan des liens, choix des pistes audio et
  fallback contrôlé de 1080p vers 720p.
- Ajoute une garde contre les boucles de changement de source, notamment
  lors des erreurs vidéo et des réponses 429.
- Revoit SeekStreaming de bout en bout : validation des embeds, prise en
  charge de plusieurs candidats, regroupement par serveur, règles de headers
  persistantes et cache d’extraction raccourci.
- Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache
  stale-while-revalidate, déduplication des requêtes et limite partagée côté API.
- Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill
  switch, développe les wrappers 1jour1film et actualise le domaine Wiflix.
- Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même
  chaîne et permet de choisir le serveur ou la qualité.
- Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi
  que de nouvelles métriques de visionnage.

Cast et application mobile

- Unifie le contrôle Cast entre le Web Cast SDK et le pont Android :
  état distant, lecture, pause, seek, arrêt et reprise après reconnexion.
- Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et
  au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground.
- Prépare les sources avant diffusion et prend en charge les playlists HLS
  TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les
  segments MPEG-TS encapsulés dans des PNG.
- Renforce le proxy média local : support Range, validation des redirections,
  liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid.
- Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités
  propres à chaque document et invalidation lors des navigations.
- Ajoute le Picture-in-Picture Android avec lecture/pause et seek de
  10 secondes depuis la fenêtre flottante.
- Maintient désormais l’écran allumé uniquement pendant une lecture active.
- Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses
  métadonnées d’intégrité.

API, base de données et paiements

- Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL
  centralisé, additif et capable de détecter les divergences de structure.
- Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL,
  confirmations pour les tables Wrapped et couverture de tests dédiée.
- Rend configurables les limites du pool MySQL et fiabilise les advisory locks.
- Revoit entièrement PayGate : références de callback séparées, nonces,
  validation des wallets et transactions, états monotones, déduplication des
  paiements et protection contre les courses ou réutilisations de txid.
- Renforce l’annulation, la réconciliation et la livraison idempotente des
  factures VIP, avec réponses no-store sur les routes sensibles.
- Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer
  correctement 401 ou 503 sans provoquer de déconnexions inutiles.
- Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en
  solution de secours.
- Améliore la gestion HLS et Range dans proxiesembed.

Frontend, profils et infrastructure

- Protège la sélection et l’hydratation des profils contre les réponses
  concurrentes, les intentions obsolètes et l’effacement accidentel du
  stockage local.
- Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données
  structurées, le service worker, robots.txt et sitemap.xml.
- Optimise les probes réseau du service worker avec cache court et single-flight.
- Charge dynamiquement les traductions anglaises, plusieurs routes secondaires
  et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires.
- Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit
  les animations, calculs et rendus hors écran.
- Ajoute la page publique des frais, les coins carrés persistants, une nouvelle
  intro Movix, les avertissements de publicités adultes et de nombreux textes
  Cast, qualité, Live TV et sources.
- Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le
  userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast.
- Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le
  proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur.
- Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et
  actualise la documentation d’installation et d’initialisation MySQL.

Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
2026-08-02 12:09:37 +02:00

657 lines
18 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
// Anti-stampede : un build lourd peut prendre plusieurs secondes a froid.
// Les requetes concurrentes attendent le resultat du detenteur du lock au lieu
// de relancer le meme agregat SQL en parallele.
const LOCK_WAIT_MS = 15000;
const LOCK_POLL_MS = 250;
// 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,
},
};
// Period filter: number of days to look back (null = all time).
const PERIODS = {
day: 1,
week: 7,
month: 30,
year: 365,
all: null,
};
const DEFAULT_PERIOD = "all";
// Ranking algorithms: whitelisted ORDER BY clauses (never user input directly).
const ALGORITHMS = {
viewers: "unique_viewers DESC, total_hours DESC",
hours: "total_hours DESC, unique_viewers DESC",
sessions: "total_sessions DESC, unique_viewers DESC",
};
const DEFAULT_ALGO = "viewers";
// Returns validated value or null (invalid input).
function parsePeriod(raw) {
if (raw === undefined) return DEFAULT_PERIOD;
const period = typeof raw === "string" ? raw.toLowerCase() : "";
return Object.prototype.hasOwnProperty.call(PERIODS, period) ? period : null;
}
function parseAlgo(raw) {
if (raw === undefined) return DEFAULT_ALGO;
const algo = typeof raw === "string" ? raw.toLowerCase() : "";
return ALGORITHMS[algo] ? algo : null;
}
/**
* 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,
period = DEFAULT_PERIOD,
algo = DEFAULT_ALGO,
) {
const cacheKey = `${category}:${period}:${algo}`;
const wrapper = await cacheGet(cacheKey);
if (wrapper && wrapper.data) {
if (isStale(wrapper)) {
scheduleBackgroundRefresh(cacheKey, () =>
refreshTop10(category, period, algo),
);
}
return wrapper.data;
}
return buildWithCacheLock(cacheKey, () =>
buildTop10ByCategory(category, period, algo),
);
}
async function buildStatsResult(requestedType, typeConfig, period = DEFAULT_PERIOD) {
const conditions = ["watch_duration >= ?"];
const statsParams = [typeConfig ? typeConfig.minDuration : 300];
if (typeConfig) {
conditions.unshift("content_type = ?");
statsParams.unshift(typeConfig.contentType);
}
const periodDays = PERIODS[period];
if (periodDays) {
conditions.push("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)");
statsParams.push(periodDays);
}
const 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 ${conditions.join("\n AND ")}
`;
const [stats] = await pool.execute(statsQuery, statsParams);
const row = stats[0] || {};
return {
success: true,
type: requestedType || "global",
period,
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, period = DEFAULT_PERIOD) {
const cacheKey = `stats:${requestedType || "global"}:${period}`;
const wrapper = await cacheGet(cacheKey);
if (wrapper && wrapper.data) {
if (isStale(wrapper)) {
scheduleBackgroundRefresh(cacheKey, () =>
refreshStats(requestedType, typeConfig, period),
);
}
return wrapper.data;
}
return buildWithCacheLock(cacheKey, () =>
buildStatsResult(requestedType, typeConfig, period),
);
}
/**
* GET /api/top10/movies|tv|anime
* Public - no auth required
* Optional query params:
* period=day|week|month|year|all (default: all)
* algo=viewers|hours|sessions (default: viewers)
*/
for (const category of Object.keys(TOP10_TYPE_CONFIG)) {
router.get(`/${category}`, async (req, res) => {
try {
if (!pool) {
return res
.status(503)
.json({ success: false, error: "Database not available" });
}
const period = parsePeriod(req.query.period);
const algo = parseAlgo(req.query.algo);
if (!period || !algo) {
return res.status(400).json({
success: false,
error: `Invalid filter. Allowed periods: ${Object.keys(PERIODS).join(", ")}. Allowed algos: ${Object.keys(ALGORITHMS).join(", ")}`,
});
}
const result = await getTop10Response(category, period, algo);
res.json(result);
} catch (error) {
console.error(`[Top10] Error fetching ${category}:`, error);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
}
/**
* GET /api/top10/overview?type=movies|tv|anime&period=all&algo=viewers
* 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 period = parsePeriod(req.query.period);
const algo = parseAlgo(req.query.algo);
if (!period || !algo) {
return res.status(400).json({
success: false,
error: `Invalid filter. Allowed periods: ${Object.keys(PERIODS).join(", ")}. Allowed algos: ${Object.keys(ALGORITHMS).join(", ")}`,
});
}
const [top10Result, statsResult] = await Promise.allSettled([
getTop10Response(requestedType, period, algo),
getStatsResponse(requestedType, typeConfig, period),
]);
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,
period,
algorithm: algo,
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 params: type=movies|tv|anime, period=day|week|month|year|all
*/
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 period = parsePeriod(req.query.period);
if (!period) {
return res.status(400).json({
success: false,
error: `Invalid period. Allowed values: ${Object.keys(PERIODS).join(", ")}`,
});
}
const result = await getStatsResponse(requestedType, typeConfig, period);
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
// ---------------------------------------------------------------------------
/**
* Recupere titre + nombre d'episodes vus pour les 10 ids retenus.
* Requete legere (IN sur 10 ids via idx_content) — permet de garder la requete
* lourde d'agregation sans content_title, donc couverte par idx_top10.
*/
async function loadContentDetailsMap(config, contentIds, periodDays) {
if (contentIds.length === 0) {
return new Map();
}
const periodClause = periodDays
? " AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)"
: "";
const params = [config.contentType, config.minDuration];
if (periodDays) params.push(periodDays);
const episodesSelect = config.withEpisodes
? "COUNT(DISTINCT CONCAT(IFNULL(season_number, ''), '-', IFNULL(episode_number, ''))) AS episodes_watched"
: "0 AS episodes_watched";
const placeholders = contentIds.map(() => "?").join(", ");
const [rows] = await pool.execute(
`
SELECT
content_id,
MAX(content_title) AS content_title,
${episodesSelect}
FROM wrapped_viewing_data
WHERE content_type = ?
AND watch_duration >= ?${periodClause}
AND content_id IN (${placeholders})
GROUP BY content_id
`,
[...params, ...contentIds],
);
return new Map(
rows.map((row) => [
String(row.content_id),
{
title: row.content_title || null,
episodesWatched: parseInt(row.episodes_watched) || 0,
},
]),
);
}
async function buildTop10ByCategory(
category,
period = DEFAULT_PERIOD,
algo = DEFAULT_ALGO,
) {
const config = getTop10TypeConfig(category);
if (!config) {
throw new Error(`Unknown Top10 category: ${category}`);
}
const orderBy = ALGORITHMS[algo] || ALGORITHMS[DEFAULT_ALGO];
const periodDays = PERIODS[period] || null;
const periodClause = periodDays
? " AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)"
: "";
const params = [config.contentType, config.minDuration];
if (periodDays) params.push(periodDays);
// Volontairement sans content_title : toutes les colonnes referencees sont
// dans idx_top10 (voir exportscripts/add_top10_index.sql) => scan index-only,
// pas de lecture de lignes. Titres recuperes ensuite sur les 10 ids retenus.
const [rows] = await pool.execute(
`
SELECT
content_id,
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 >= ?${periodClause}
GROUP BY content_id
ORDER BY ${orderBy}
LIMIT 10
`,
params,
);
const detailsByContentId = await loadContentDetailsMap(
config,
rows.map((row) => row.content_id),
periodDays,
);
const enriched = await Promise.all(
rows.map(async (row, index) => {
const tmdb = await fetchTMDBDetails(row.content_id, config.tmdbType);
const details = detailsByContentId.get(String(row.content_id));
return {
rank: index + 1,
contentId: row.content_id,
title:
tmdb?.title ||
details?.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
? details?.episodesWatched || 0
: undefined,
};
}),
);
return {
success: true,
type: category,
period,
algorithm: algo,
top10: enriched,
updatedAt: new Date().toISOString(),
};
}
// ---------------------------------------------------------------------------
// Background refresh (stale-while-revalidate)
// ---------------------------------------------------------------------------
async function refreshTop10(category, period = DEFAULT_PERIOD, algo = DEFAULT_ALGO) {
try {
const cacheKey = `${category}:${period}:${algo}`;
const result = await buildTop10ByCategory(category, period, algo);
await cacheSet(cacheKey, result);
console.log(`[Top10] Cache ${cacheKey} refreshed in background`);
} catch (err) {
console.error(`[Top10] Refresh error for ${category}:`, err.message);
}
}
async function refreshStats(requestedType, typeConfig, period = DEFAULT_PERIOD) {
try {
const cacheKey = `stats:${requestedType || "global"}:${period}`;
const result = await buildStatsResult(requestedType, typeConfig, period);
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 };