mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 17:07:35 +00:00
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>
256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
/**
|
||
* SwiftFlow source — partner JSON API keyed by TMDB id (no scraping).
|
||
* Mount point: app.use('/api/swiftflow', require('./routes/swiftflow'))
|
||
*
|
||
* Endpoints (TMDB id in, players out — same contract as fstream/wiflix/j1f):
|
||
* GET /movie/:id
|
||
* GET /tv/:id/season/:season (optional ?episode=N)
|
||
*
|
||
* Upstream (base + key env-overridable):
|
||
* ?route=movies/{id} -> { success, data:{ title, year, sources:[{language,quality,size,...}] } }
|
||
* ?route=series/{id} -> { success, data:{ seasons:[{ season:"S01", episodes:[...] }] } }
|
||
* not found -> HTTP 404 { success:false }
|
||
* Player iframe handed to the frontend:
|
||
* movies: ?route=movies/{id}/player&api_key=...
|
||
* series: ?route=series/{id}/player&api_key=...&season={s}&episode={e}
|
||
* (vérifié: season/episode sont bien honorés par le player série)
|
||
*
|
||
* Cache — la limite upstream est 1000 req/min, donc on ne touche SwiftFlow
|
||
* qu'une fois par titre par fenêtre TTL :
|
||
* - stale-while-revalidate sur le cache disque+mémoire partagé (stamp _ts) :
|
||
* frais -> servi ; périmé -> stale servi tout de suite + refresh en fond ;
|
||
* froid -> fetch inline (API JSON rapide). Tout est dédupliqué in-flight.
|
||
* - les négatifs (404) sont cachés aussi — la plupart des ids TMDB ne sont
|
||
* PAS sur SwiftFlow, c'est le gros du volume.
|
||
* - TTL : films 24h (une entrée catalogue ne bouge plus), séries 6h (les
|
||
* épisodes tombent chaque semaine), négatifs 6h (un nouvel ajout devient
|
||
* visible en <= 6h).
|
||
* - une erreur upstream (timeout/5xx) n'est JAMAIS cachée : le stale reste
|
||
* servi et la requête suivante retente le refresh.
|
||
* - garde-fou: max ~900 appels upstream/min, au-delà on échoue vite (non
|
||
* caché) au lieu de brûler la limite.
|
||
*/
|
||
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const axios = require('axios');
|
||
|
||
const {
|
||
CACHE_DIR,
|
||
getFromCacheNoExpiration,
|
||
saveToCache,
|
||
} = require('../utils/cacheManager');
|
||
const { redis } = require('../config/redis');
|
||
|
||
const API_BASE = (process.env.SWIFTFLOW_BASE_URL || '').replace(/\/+$/, '');
|
||
// Clé non secrète (SwiftFlow l'embarque dans l'URL du player côté client), mais
|
||
// pas de fallback hardcodé — doit venir de l'env comme la base.
|
||
const API_KEY = process.env.SWIFTFLOW_API_KEY || '';
|
||
|
||
// Sans base+clé, apiUrl() produit une URL relative -> axios throw "Invalid URL"
|
||
// à chaque requête. On désactive proprement la source et on prévient une fois.
|
||
const CONFIGURED = Boolean(API_BASE && API_KEY);
|
||
if (!CONFIGURED) {
|
||
console.warn('[SwiftFlow] SWIFTFLOW_BASE_URL / SWIFTFLOW_API_KEY manquants — source désactivée');
|
||
}
|
||
|
||
const MOVIE_TTL_MS = 24 * 60 * 60 * 1000;
|
||
const TV_TTL_MS = 6 * 60 * 60 * 1000;
|
||
const NEGATIVE_TTL_MS = 6 * 60 * 60 * 1000;
|
||
const UPSTREAM_TIMEOUT_MS = 10000;
|
||
const UPSTREAM_MAX_PER_MIN = 900; // marge sous la limite SwiftFlow (1000/min)
|
||
|
||
const apiUrl = (route) => `${API_BASE}/api/v1/index.php?route=${route}&api_key=${API_KEY}`;
|
||
const moviePlayerUrl = (id) => apiUrl(`movies/${id}/player`);
|
||
const episodePlayerUrl = (id, season, episode) =>
|
||
`${apiUrl(`series/${id}/player`)}&season=${season}&episode=${episode}`;
|
||
|
||
// === Garde-fou rate limit upstream (fenêtre fixe 60s, partagée cluster) ===
|
||
// Un compteur en mémoire serait PAR WORKER: en cluster à N workers ça
|
||
// autoriserait N×MAX appels/min et exploserait la limite SwiftFlow (1000/min).
|
||
// On passe donc par Redis INCR sur une clé bucketée à la minute, partagée entre
|
||
// tous les workers. Redis down -> fail-open (le cache SWR limite déjà la
|
||
// pression upstream; cohérent avec memoryCache qui dégrade en cache-miss).
|
||
// ponytail: fenêtre fixe suffit; sliding window si les bursts à la frontière de
|
||
// minute posent problème un jour.
|
||
async function takeUpstreamSlot() {
|
||
const bucketKey = `swiftflow:rl:${Math.floor(Date.now() / 60000)}`;
|
||
try {
|
||
const count = await redis.incr(bucketKey);
|
||
if (count === 1) await redis.expire(bucketKey, 120); // TTL > fenêtre, cleanup best-effort
|
||
return count <= UPSTREAM_MAX_PER_MIN;
|
||
} catch {
|
||
return true; // Redis injoignable: ne bloque pas le trafic
|
||
}
|
||
}
|
||
|
||
// GET upstream. 404 = négatif définitif (cachable). Tout autre échec = throw,
|
||
// jamais caché — le stale existant continue d'être servi.
|
||
async function fetchUpstream(route) {
|
||
if (!(await takeUpstreamSlot())) throw new Error('budget upstream épuisé (>900 req/min)');
|
||
const res = await axios.get(apiUrl(route), {
|
||
timeout: UPSTREAM_TIMEOUT_MS,
|
||
validateStatus: (s) => s === 200 || s === 404,
|
||
});
|
||
if (res.status === 404 || !res.data || res.data.success !== true) return null;
|
||
return res.data.data;
|
||
}
|
||
|
||
// Enveloppe cachée sur disque: { kind, notFound, data, _ts }
|
||
const envelope = (kind, data) => ({ kind, notFound: !data, data: data || null, _ts: Date.now() });
|
||
|
||
const fetchMovie = (id) =>
|
||
fetchUpstream(`movies/${id}`).then((d) =>
|
||
envelope('movie', d && Array.isArray(d.sources) && d.sources.length ? d : null),
|
||
);
|
||
// La série entière est cachée sous UNE clé: 1 appel upstream couvre toutes les
|
||
// saisons/épisodes, le découpage par saison se fait depuis le cache.
|
||
const fetchSeries = (id) =>
|
||
fetchUpstream(`series/${id}`).then((d) =>
|
||
envelope('tv', d && Array.isArray(d.seasons) && d.seasons.length ? d : null),
|
||
);
|
||
|
||
// In-flight dédup: les requêtes concurrentes pour la même clé partagent le
|
||
// même appel upstream (refresh de fond comme fetch froid).
|
||
const inFlight = new Map();
|
||
function fetchAndCache(key, fetcher) {
|
||
if (inFlight.has(key)) return inFlight.get(key);
|
||
const job = (async () => {
|
||
try {
|
||
const fresh = await fetcher();
|
||
await saveToCache(CACHE_DIR.SWIFTFLOW, key, fresh);
|
||
return fresh;
|
||
} finally {
|
||
inFlight.delete(key);
|
||
}
|
||
})();
|
||
inFlight.set(key, job);
|
||
job.catch(() => {}); // refresh de fond raté: le prochain hit retentera
|
||
return job;
|
||
}
|
||
|
||
// Retourne l'enveloppe cache à servir, ou null si froid (aucune entrée) — dans
|
||
// ce cas la route répond { pending:true } et le fetch tourne en fond.
|
||
async function withCache(key, fetcher) {
|
||
const cached = await getFromCacheNoExpiration(CACHE_DIR.SWIFTFLOW, key);
|
||
if (cached && cached._ts) {
|
||
const ttl = cached.notFound
|
||
? NEGATIVE_TTL_MS
|
||
: cached.kind === 'movie'
|
||
? MOVIE_TTL_MS
|
||
: TV_TTL_MS;
|
||
if (Date.now() - cached._ts >= ttl) fetchAndCache(key, fetcher); // stale: refresh en fond
|
||
return cached; // frais OU stale: servi tout de suite
|
||
}
|
||
// Froid: fetch en fond (dédupliqué) et on répond "en cours" sans bloquer. Comme
|
||
// j1f — évite qu'un SwiftFlow froid/lent (timeout 10s) ne gate le Promise.all
|
||
// de chargement des sources côté front. Le client récupère les données au hit
|
||
// suivant (cache chaud).
|
||
fetchAndCache(key, fetcher);
|
||
return null;
|
||
}
|
||
|
||
const isVostfr = (lang) => /vostfr/i.test(lang || '');
|
||
const buildLabel = (item) =>
|
||
[item && item.quality, item && item.size].filter((v) => v && v !== 'Unknown').join(' · ');
|
||
|
||
function buildMovieResponse(id, env) {
|
||
if (!env || env.notFound || !env.data) {
|
||
return { success: false, error: 'Film non disponible sur SwiftFlow', tmdb_id: id };
|
||
}
|
||
const sources = env.data.sources || [];
|
||
// Un seul player par film; VOSTFR seulement si aucune source VF.
|
||
const vostfrOnly = sources.length > 0 && sources.every((s) => isVostfr(s.language));
|
||
const player = {
|
||
name: 'SwiftFlow',
|
||
url: moviePlayerUrl(id),
|
||
type: 'iframe',
|
||
label: buildLabel(sources[0]),
|
||
};
|
||
return {
|
||
success: true,
|
||
tmdb_id: Number(id),
|
||
title: env.data.title,
|
||
year: env.data.year,
|
||
source: 'swiftflow',
|
||
players: { vf: vostfrOnly ? [] : [player], vostfr: vostfrOnly ? [player] : [] },
|
||
cache_timestamp: new Date(env._ts).toISOString(),
|
||
};
|
||
}
|
||
|
||
// Shape alignée sur wiflix/j1f TV: `episodes` keyed par numéro d'épisode,
|
||
// chacun { vf:[{name,url,type,label}], vostfr:[...] }.
|
||
function buildSeasonResponse(id, env, seasonNum, episodeNum) {
|
||
if (!env || env.notFound || !env.data) {
|
||
return { success: false, error: 'Série non disponible sur SwiftFlow', tmdb_id: id };
|
||
}
|
||
const season = (env.data.seasons || []).find(
|
||
(s) => parseInt(String(s.season).replace(/\D/g, ''), 10) === Number(seasonNum),
|
||
);
|
||
if (!season) {
|
||
return { success: false, error: `Saison ${seasonNum} non disponible`, tmdb_id: id };
|
||
}
|
||
|
||
const episodes = {};
|
||
for (const ep of season.episodes || []) {
|
||
if (!ep || ep.episode_number == null) continue;
|
||
if (episodeNum && Number(ep.episode_number) !== Number(episodeNum)) continue;
|
||
const entry = {
|
||
name: 'SwiftFlow',
|
||
url: episodePlayerUrl(id, Number(seasonNum), ep.episode_number),
|
||
type: 'iframe',
|
||
label: buildLabel(ep),
|
||
};
|
||
episodes[String(ep.episode_number)] = isVostfr(ep.language)
|
||
? { vf: [], vostfr: [entry] }
|
||
: { vf: [entry], vostfr: [] };
|
||
}
|
||
if (Object.keys(episodes).length === 0) {
|
||
return { success: false, error: 'Aucun épisode disponible', tmdb_id: id };
|
||
}
|
||
return {
|
||
success: true,
|
||
tmdb_id: Number(id),
|
||
title: env.data.series_name || env.data.title,
|
||
source: 'swiftflow',
|
||
season: Number(seasonNum),
|
||
episodes,
|
||
cache_timestamp: new Date(env._ts).toISOString(),
|
||
};
|
||
}
|
||
|
||
// === Routes ===
|
||
router.get('/movie/:id', async (req, res) => {
|
||
const { id } = req.params;
|
||
if (!/^\d+$/.test(id)) {
|
||
return res.status(400).json({ success: false, error: 'TMDB id invalide' });
|
||
}
|
||
if (!CONFIGURED) return res.json({ success: false, error: 'SwiftFlow non configuré', tmdb_id: id });
|
||
try {
|
||
const env = await withCache(`movie-${id}`, () => fetchMovie(id));
|
||
if (!env) return res.json({ success: false, pending: true, tmdb_id: Number(id) });
|
||
res.json(buildMovieResponse(id, env));
|
||
} catch (err) {
|
||
console.error(`[SwiftFlow MOVIE] ${id}: ${err.message}`);
|
||
res.status(200).json({ success: false, error: 'Erreur SwiftFlow', tmdb_id: id });
|
||
}
|
||
});
|
||
|
||
router.get('/tv/:id/season/:season', async (req, res) => {
|
||
const { id, season } = req.params;
|
||
const { episode } = req.query;
|
||
if (!/^\d+$/.test(id) || !/^\d+$/.test(season)) {
|
||
return res.status(400).json({ success: false, error: 'Paramètres invalides' });
|
||
}
|
||
if (!CONFIGURED) return res.json({ success: false, error: 'SwiftFlow non configuré', tmdb_id: id });
|
||
try {
|
||
const env = await withCache(`tv-${id}`, () => fetchSeries(id));
|
||
if (!env) return res.json({ success: false, pending: true, tmdb_id: Number(id) });
|
||
res.json(buildSeasonResponse(id, env, season, episode));
|
||
} catch (err) {
|
||
console.error(`[SwiftFlow TV] ${id} S${season}: ${err.message}`);
|
||
res.status(200).json({ success: false, error: 'Erreur SwiftFlow', tmdb_id: id });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|