mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-07 20:00:10 +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>
277 lines
8.8 KiB
Python
277 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Literal, Mapping, Sequence
|
|
from urllib.parse import unquote, urlencode, urlsplit, urlunsplit
|
|
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Util.Padding import unpad
|
|
|
|
VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
LEGACY_IPV4_PART_RE = re.compile(r"(?:[0-9]+|0[xX][0-9A-Fa-f]+)")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeekStreamingEmbed:
|
|
host: str
|
|
video_id: str
|
|
origin: str
|
|
referer: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeekStreamingCandidate:
|
|
kind: Literal["cfNative", "source"]
|
|
url: str
|
|
|
|
|
|
def _decode_url(raw_url: str) -> str:
|
|
decoded = raw_url.strip()
|
|
for _ in range(2):
|
|
next_value = unquote(decoded)
|
|
if next_value == decoded:
|
|
break
|
|
decoded = next_value
|
|
return decoded
|
|
|
|
|
|
def normalize_seekstreaming_origin(raw_url: str) -> str:
|
|
if not isinstance(raw_url, str) or CONTROL_RE.search(raw_url):
|
|
raise ValueError("Invalid SeekStreaming origin")
|
|
parsed = urlsplit(raw_url.strip())
|
|
host = (parsed.hostname or "").rstrip(".").lower()
|
|
try:
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid SeekStreaming origin") from exc
|
|
if (
|
|
parsed.scheme != "https"
|
|
or not host
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or port not in (None, 443)
|
|
):
|
|
raise ValueError("Invalid SeekStreaming origin")
|
|
return f"https://{host}"
|
|
|
|
|
|
def parse_seekstreaming_embed_url(raw_url: str) -> SeekStreamingEmbed:
|
|
if not isinstance(raw_url, str) or CONTROL_RE.search(raw_url):
|
|
raise ValueError("Invalid SeekStreaming URL")
|
|
decoded = _decode_url(raw_url)
|
|
parsed = urlsplit(decoded)
|
|
host = (parsed.hostname or "").rstrip(".").lower()
|
|
try:
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid SeekStreaming URL") from exc
|
|
if (
|
|
parsed.scheme != "https"
|
|
or not host
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or port not in (None, 443)
|
|
or parsed.path != "/"
|
|
or bool(parsed.query)
|
|
):
|
|
raise ValueError("Invalid SeekStreaming URL")
|
|
video_id = parsed.fragment.split("&", 1)[0].split("?", 1)[0].strip()
|
|
if not VIDEO_ID_RE.fullmatch(video_id):
|
|
raise ValueError("Invalid SeekStreaming video ID")
|
|
origin = normalize_seekstreaming_origin(decoded)
|
|
return SeekStreamingEmbed(host=host, video_id=video_id, origin=origin, referer=f"{origin}/")
|
|
|
|
|
|
def validate_seekstreaming_media_url(raw_url: object) -> str:
|
|
if not isinstance(raw_url, str) or CONTROL_RE.search(raw_url):
|
|
raise ValueError("Invalid SeekStreaming media URL")
|
|
parsed = urlsplit(raw_url.strip())
|
|
host = (parsed.hostname or "").rstrip(".").lower()
|
|
if (
|
|
parsed.scheme not in ("http", "https")
|
|
or not host
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or host == "localhost"
|
|
or host.endswith(".local")
|
|
):
|
|
raise ValueError("Invalid SeekStreaming media URL")
|
|
try:
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid SeekStreaming media URL") from exc
|
|
if port is not None and not 1 <= port <= 65535:
|
|
raise ValueError("Invalid SeekStreaming media URL")
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
address = None
|
|
if address is not None:
|
|
try:
|
|
validate_seekstreaming_resolved_address(host)
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid SeekStreaming media URL") from exc
|
|
if address is None:
|
|
parts = host.split(".")
|
|
if parts and all(LEGACY_IPV4_PART_RE.fullmatch(part) for part in parts):
|
|
raise ValueError("Invalid SeekStreaming media URL")
|
|
return urlunsplit(parsed)
|
|
|
|
|
|
def validate_seekstreaming_resolved_address(raw_address: object) -> str:
|
|
if not isinstance(raw_address, str):
|
|
raise ValueError("Invalid SeekStreaming resolved address")
|
|
try:
|
|
address = ipaddress.ip_address(raw_address)
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid SeekStreaming resolved address") from exc
|
|
if not address.is_global or address.is_multicast:
|
|
raise ValueError("Invalid SeekStreaming resolved address")
|
|
return str(address)
|
|
|
|
|
|
def decrypt_seekstreaming_payload(ciphertext: str, key: bytes, iv: bytes) -> dict[str, object]:
|
|
if not isinstance(ciphertext, str):
|
|
raise ValueError("Invalid encrypted payload")
|
|
cleaned = ciphertext.strip().strip('"')
|
|
if not cleaned or len(cleaned) % 32 != 0 or not re.fullmatch(r"[0-9a-fA-F]+", cleaned):
|
|
raise ValueError("Invalid encrypted payload")
|
|
decrypted = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(bytes.fromhex(cleaned)), AES.block_size)
|
|
payload = json.loads(decrypted.decode("utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("Invalid decrypted payload")
|
|
return payload
|
|
|
|
|
|
def _is_historical_cf_master(raw_url: object) -> bool:
|
|
if not isinstance(raw_url, str):
|
|
return False
|
|
decoded = raw_url.strip()
|
|
for _ in range(16):
|
|
if "cf-master" in decoded.lower():
|
|
return True
|
|
next_value = unquote(decoded)
|
|
if next_value == decoded:
|
|
return False
|
|
decoded = next_value
|
|
# Excessive encoding cannot be a directly playable URL. Reject it rather
|
|
# than letting an opaque legacy candidate shadow master/masterUrl.
|
|
return True
|
|
|
|
|
|
def extract_seekstreaming_candidates(payload: Mapping[str, object]) -> tuple[SeekStreamingCandidate, ...]:
|
|
ordered: list[SeekStreamingCandidate] = []
|
|
seen: set[str] = set()
|
|
|
|
source_value = None
|
|
for source_key in ("source", "master", "masterUrl"):
|
|
raw_source = payload.get(source_key)
|
|
if _is_historical_cf_master(raw_source):
|
|
continue
|
|
try:
|
|
source_value = validate_seekstreaming_media_url(raw_source)
|
|
except ValueError:
|
|
continue
|
|
break
|
|
|
|
for kind, raw_url in (("cfNative", payload.get("cfNative")), ("source", source_value)):
|
|
try:
|
|
url = validate_seekstreaming_media_url(raw_url)
|
|
except ValueError:
|
|
continue
|
|
if url in seen:
|
|
continue
|
|
seen.add(url)
|
|
ordered.append(SeekStreamingCandidate(kind=kind, url=url))
|
|
return tuple(ordered)
|
|
|
|
|
|
def build_seekstreaming_cache_key(host: str, video_id: str) -> str:
|
|
return hashlib.sha256(f"{host.lower()}\0{video_id}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _proxy_candidate_url(
|
|
candidate: SeekStreamingCandidate,
|
|
*,
|
|
proxy_base: str,
|
|
embed_origin: str,
|
|
cache_key: str,
|
|
) -> str:
|
|
query = urlencode({
|
|
"url": candidate.url,
|
|
"referer": f"{embed_origin}/",
|
|
"origin": embed_origin,
|
|
"cache_key": cache_key,
|
|
})
|
|
return f"{proxy_base.rstrip('/')}/seekstreaming-proxy?{query}"
|
|
|
|
|
|
def build_seekstreaming_result(
|
|
candidates: Sequence[SeekStreamingCandidate],
|
|
*,
|
|
proxy_base: str,
|
|
embed_origin: str,
|
|
cache_key: str,
|
|
) -> dict[str, object]:
|
|
proxied = [
|
|
{
|
|
"kind": candidate.kind,
|
|
"url": _proxy_candidate_url(
|
|
candidate,
|
|
proxy_base=proxy_base,
|
|
embed_origin=embed_origin,
|
|
cache_key=cache_key,
|
|
),
|
|
}
|
|
for candidate in candidates
|
|
]
|
|
if not proxied:
|
|
raise ValueError("No SeekStreaming playback candidate")
|
|
result: dict[str, object] = {
|
|
"source": "seekstreaming",
|
|
"url": proxied[0]["url"],
|
|
"candidates": proxied,
|
|
}
|
|
source = next((item for item in proxied if item["kind"] == "source"), None)
|
|
if source:
|
|
result["ip_url"] = source["url"]
|
|
return result
|
|
|
|
|
|
def redact_url_for_log(raw_url: str) -> str:
|
|
try:
|
|
parsed = urlsplit(raw_url)
|
|
host = parsed.hostname
|
|
port = parsed.port
|
|
if not parsed.scheme or not host:
|
|
return "<redacted>"
|
|
authority = f"[{host}]" if ":" in host else host
|
|
if port is not None:
|
|
authority = f"{authority}:{port}"
|
|
return f"{parsed.scheme}://{authority}/<redacted>"
|
|
except Exception:
|
|
return "<redacted>"
|
|
|
|
|
|
def is_hls_response(
|
|
request_url: str,
|
|
response_url: str,
|
|
content_type: str,
|
|
body_prefix: bytes | None = None,
|
|
) -> bool:
|
|
if body_prefix is not None:
|
|
return has_hls_manifest_signature(body_prefix)
|
|
urls = (request_url.lower().split("?", 1)[0], response_url.lower().split("?", 1)[0])
|
|
return (
|
|
any(url.endswith((".m3u8", ".m3u")) for url in urls)
|
|
or "mpegurl" in content_type.lower()
|
|
)
|
|
|
|
|
|
def has_hls_manifest_signature(body: bytes) -> bool:
|
|
return bool(body) and body.lstrip().startswith(b"#EXTM3U")
|