MovixOpenSource/API/proxiesembed/seekstreaming_utils.py
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

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")