mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775caad0ee | ||
|
|
4d00409de8 | ||
|
|
0ea60ccbfa | ||
|
|
a3640a6b5f | ||
|
|
584068b45b | ||
|
|
abe7d522c1 | ||
|
|
8d79472ca8 | ||
|
|
6812a4964d | ||
|
|
fdf98f9659 | ||
|
|
6212948ea5 | ||
|
|
662e1034e5 | ||
|
|
a9b43ff581 | ||
|
|
327873d254 | ||
|
|
da67c479b3 | ||
|
|
9f4e98fcea | ||
|
|
117523f153 | ||
|
|
08d841180f | ||
|
|
73469060ff | ||
|
|
e19afd00f1 | ||
|
|
db13c85c1d | ||
|
|
6d40c38d30 | ||
|
|
9843999809 | ||
|
|
bde32adda9 | ||
|
|
b6ca9dab96 | ||
|
|
2151f2820c | ||
|
|
fa8eed38f2 | ||
|
|
b95d72fe80 | ||
|
|
d6c7184475 | ||
|
|
175cff560c | ||
|
|
e5079127ab | ||
|
|
4e453850ae | ||
|
|
1295bd12ef | ||
|
|
dfc8a33916 | ||
|
|
85a8d16542 | ||
|
|
7fa9897d5c |
No files matched your search
@@ -8,7 +8,6 @@
|
||||
build/
|
||||
bin/.dart_tool
|
||||
bin/.packages
|
||||
bin/pubspec.lock
|
||||
ios/
|
||||
android/
|
||||
windows/
|
||||
|
||||
@@ -6,6 +6,12 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Un push qui en suit un autre annule le run précédent : inutile de
|
||||
# consommer un runner pour un commit déjà obsolète.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,12 +19,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
# Version épinglée : `channel: stable` seul fait dériver le SDK au
|
||||
# fil du temps (une release Flutter peut casser la CI sans aucun
|
||||
# changement dans le dépôt). Le cache évite de retélécharger SDK et
|
||||
# dépendances pub à chaque run.
|
||||
flutter-version: 3.47.6
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
# Infos (pre-existing withOpacity/dart:html deprecations) are not
|
||||
# fatal; errors and warnings still fail the build.
|
||||
- run: flutter analyze --no-fatal-infos
|
||||
- run: flutter test
|
||||
# Moteur de lecture web (web/xf-player-core.js) : Node est préinstallé
|
||||
# sur les runners ubuntu, aucune dépendance npm.
|
||||
- run: node --test "test/web/*_test.mjs"
|
||||
- run: flutter build web --release
|
||||
|
||||
backend:
|
||||
@@ -30,7 +45,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
sdk: 3.13.5
|
||||
- run: dart pub get
|
||||
- run: dart analyze
|
||||
- run: dart test
|
||||
@@ -1,7 +1,12 @@
|
||||
name: Docker Publish
|
||||
|
||||
# Chaîné sur la CI : l'image :latest n'est publiée que si analyze/test/build
|
||||
# sont verts sur main. L'ancien déclencheur `push: [main]` tournait en
|
||||
# parallèle de la CI et pouvait publier une image cassée.
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -11,11 +16,18 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Sur workflow_run, github.sha pointe sur le commit du workflow
|
||||
# par défaut : construire exactement le commit validé par la CI.
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -34,6 +46,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
${{ env.IMAGE_NAME }}:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -49,4 +49,7 @@ app.*.map.json
|
||||
# Hive data (local development)
|
||||
*.hive
|
||||
*.lock
|
||||
# Les lockfiles pub sont versionnés : sans eux, chaque build Docker/CI
|
||||
# résout des versions différentes (builds non reproductibles).
|
||||
!pubspec.lock
|
||||
.claude/settings.local.json
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
## Non publié
|
||||
|
||||
### 🖼️ Logos des chaînes
|
||||
- **Logos de repli quand l'hébergeur de picons tombe** : nouvelle route `/api/logo?src=…&name=…`. L'URL du panneau est tentée d'abord (via le proxy, mêmes protections anti-SSRF) ; en cas d'échec, le logo est cherché par nom de chaîne dans le dépôt public [`tv-logo/tv-logos`](https://github.com/tv-logo/tv-logos) — préfixe pays et suffixes de qualité ignorés, `Canal+` → `canal-plus`, repli sur la marque pour les canaux événementiels (`RMC SPORT LIVE 5` → logo RMC Sport). La liste des logos est mise en cache 24 h (l'API GitHub non authentifiée plafonne à 60 requêtes/h), les images en mémoire. Les chaînes sans `stream_icon` y gagnent aussi un logo
|
||||
- **Plus d'acharnement sur un hébergeur de picons hors service** : une image en erreur n'est plus relayée telle quelle au navigateur, qui la redemandait à chaque affichage de la grille. Réponse 410 mise en cache dix minutes (410 et non 404 : la `Cascade` du serveur rattrape les 404 et perdait l'en-tête de cache) ; au bout de trois échecs consécutifs, l'hôte est considéré hors service cinq minutes et plus aucune requête ne sort. Un statut d'erreur plutôt qu'un pixel transparent : le client affiche son propre repli au lieu d'une tuile vide
|
||||
- **Logos de l'onglet Enregistrements** : l'URL `http://` du panneau y était chargée directement, bloquée par le navigateur sur une page servie en HTTPS
|
||||
|
||||
### 📺 Guide TV (EPG)
|
||||
- **Guide servi par le dump `xmltv.php` du panneau** : `player_api` réclamait un appel par chaîne, à plusieurs secondes pièce chez la plupart des revendeurs — une grille de trente chaînes mettait plus d'une minute à se remplir. Le dump du panneau couvre toutes les chaînes en une requête, réutilisée trois heures. L'interrogation chaîne par chaîne reste le repli quand le dump est absent ou ne couvre pas la chaîne, suivie de la source XMLTV externe
|
||||
- **Une source XMLTV morte n'est plus retéléchargée à chaque consultation** : après un échec, la source est laissée de côté quinze minutes
|
||||
- **Index XMLTV borné à 48 h** : un dump national couvre sept jours pour un millier de chaînes ; tout garder coûtait des centaines de mégaoctets au conteneur pour un guide qui n'affiche que le programme courant et les suivants
|
||||
|
||||
### ▶️ Lecture vidéo
|
||||
- **Fix du démarrage des enregistrements** : la lecture partait au milieu du programme et se coupait aussitôt, obligeant à relancer une deuxième fois. Une playlist encore en cours de transcodage n'a pas d'`EXT-X-ENDLIST` : hls.js la traite comme du direct et démarrait donc au « bord du direct », collé au front d'encodage, sans aucune avance de segments. `startPosition: 0` hors direct, plus la neutralisation du rattrapage de latence (qui accélérait la lecture puis forçait un saut en avant pour rejoindre un direct inexistant)
|
||||
- **Plus de ré-encodage inutile à la lecture d'un enregistrement** : la capture étant faite en copie, le fichier contient déjà du H.264 dans la quasi-totalité des cas. Il est désormais servi tel quel (`-c:v copy`, seul l'audio est converti en AAC) au lieu d'être ré-encodé à peine plus vite que le temps réel — la segmentation va maintenant à la vitesse du disque, l'enregistrement devient navigable en quelques secondes. Les codecs illisibles par le navigateur (HEVC, MPEG-2…) restent ré-encodés
|
||||
- **Démarrage plus robuste** : le serveur attend trois segments d'avance avant de servir la playlist (au lieu d'un seul, qui laissait le lecteur courir après l'encodeur), et sert ce qu'il a plutôt qu'une erreur si le délai expire. Côté navigateur, le délai d'attente de playlist passe de 10 s à 45 s hors direct — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort — et le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le faisait déjà le lecteur mobile
|
||||
- **Lecteur avancé pour les enregistrements** : barre de progression réellement utilisable (avance, retour, saut n'importe où), pause/reprise, et reprise automatique là où on s'était arrêté. La playlist d'un enregistrement était servie en `EXT-X-PLAYLIST-TYPE:VOD` : hls.js la considérait comme définitive et ne voyait donc que les quelques secondes déjà transcodées à l'ouverture. Elle passe en `EVENT` (la zone navigable grandit avec l'encodage) et un saut hors de cette zone relance FFmpeg à la position visée (`?start=`), au lieu d'attendre que l'encodeur y arrive
|
||||
- **Reprise des enregistrements** : la liste affiche « Reprendre à … » avec la progression, et propose au clic de reprendre ou de repartir du début ; la durée affichée est mesurée par ffprobe (`duration_seconds`) et non plus déduite des horaires programmés — un enregistrement arrêté en avance donnait une barre fausse
|
||||
- **Contrôles enrichis (VOD, séries, enregistrements)** : sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre, et raccourcis clavier `K`/espace, `J`/`L`, Maj+←/→, `0`-`9`, `F`, `M`, `,`/`.`
|
||||
- **Fix du son manquant sur certaines chaînes** : nouvelle route `/api/live/<id>/turbo.ts` pour le zapping — vidéo copiée telle quelle, audio systématiquement réencodé en AAC côté serveur. Les chaînes en AC-3/E-AC-3/MP2 (que mpegts.js ne décode pas) ont maintenant du son, sans coût de transcodage vidéo
|
||||
- **Players mis à jour** : hls.js 1.6.7 → 1.7.1, mpegts.js 1.7.3 → 1.8.2 (vendorisés)
|
||||
- **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur)
|
||||
- **Latence live maîtrisée** : rattrapage du direct activé dans mpegts.js (profil rapide) — les micro-coupures ne font plus dériver la lecture derrière le direct
|
||||
- **Fix « Échec du chargement : vendor/mpegts.min.js »** : un échec de chargement d'une lib de lecture n'affiche plus un écran d'erreur définitif. Le chargement est retenté une fois en contournant le cache HTTP (une entrée tronquée condamnait le lecteur jusqu'au vidage manuel du cache), le résultat n'est mémorisé qu'en cas de succès (une promesse rejetée en cache rendait tout réessai impossible) et, si la lib reste introuvable, le live bascule automatiquement sur la route HLS équivalente. Le message affiché précise désormais la cause (HTTP 404, 429, réseau injoignable)
|
||||
- **Préchargement de la bonne lib** : les trois players préchargeaient hls.js en dur, soit 618 Ko téléchargés pour rien à chaque zap TV — où c'est mpegts.js qui sert — au détriment du flux et du chargement de mpegts.js. Le préchargement suit maintenant le flux réellement demandé (et ne charge rien sur Safari/iOS, qui lit le HLS nativement)
|
||||
- **Contre-pression sur le flux turbo** : le relais de FFmpeg vers le navigateur ne transmettait pas la pause du client à la source. Un lecteur plus lent que le flux — réseau domestique, onglet en arrière-plan — laissait FFmpeg produire à pleine vitesse pendant que le serveur empilait les paquets en mémoire : la lecture dérivait derrière le direct et saccadait
|
||||
- **Alerte au démarrage** : le serveur signale explicitement l'absence de `web/vendor/*.min.js` au lancement, au lieu de laisser le navigateur échouer sans explication
|
||||
|
||||
### 🧰 Qualité / Infra
|
||||
- **Builds reproductibles** : `pubspec.lock` (frontend et backend) désormais versionnés et utilisés par le Dockerfile — chaque build résolvait jusqu'ici des versions fraîches
|
||||
- **CI durcie** : versions Flutter/Dart épinglées, cache pub, annulation des runs obsolètes (`concurrency`) ; `docker-publish` ne publie plus `:latest` qu'après une CI verte sur main (il tournait en parallèle et pouvait publier une image cassée)
|
||||
- **docker-compose** : défaut `RECORDINGS_PATH` porté à `./data/recordings` (l'ancien défaut était un chemin unRAID spécifique à une machine), variable `TZ` ajoutée
|
||||
- **README réécrit** : il décrivait une architecture disparue (Hive/IndexedDB, SHA-256, dhttpd port 8080) — remplacé par l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, CI)
|
||||
- `DEPLOYMENT_CHECKLIST.md` archivé et avertissement ajouté sur `docs/archive/` (plusieurs documents s'y déclarent « COMPLETE » à tort)
|
||||
|
||||
### ✨ Fonctionnalités
|
||||
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
|
||||
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
|
||||
|
||||
+25
-5
@@ -1,7 +1,26 @@
|
||||
# Stage 1: Build Environment (Optimized for Docker Caching)
|
||||
FROM ghcr.io/cirruslabs/flutter:stable AS builder
|
||||
# SDK Flutter officiel, version épinglée — la même que la CI. L'image
|
||||
# ghcr.io/cirruslabs/flutter:stable n'est plus mise à jour : elle était
|
||||
# restée sur Flutter 3.44.0 / Dart 3.12.0 (mai 2026), loin derrière la
|
||||
# version stable réelle.
|
||||
FROM debian:stable-slim AS builder
|
||||
|
||||
ARG FLUTTER_VERSION=3.47.6
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
unzip \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" \
|
||||
| tar -xJ -C /opt \
|
||||
&& git config --global --add safe.directory /opt/flutter
|
||||
|
||||
ENV PATH="/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:${PATH}"
|
||||
|
||||
USER root
|
||||
WORKDIR /app
|
||||
|
||||
# Optimize DART VM Memory
|
||||
@@ -12,9 +31,10 @@ ENV FLUTTER_NO_ANALYTICS=1
|
||||
RUN flutter config --enable-web && flutter precache --web
|
||||
|
||||
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
||||
COPY pubspec.yaml ./
|
||||
COPY bin/pubspec.yaml ./bin/
|
||||
# Note: No .lock files found locally, so we fetch fresh ones here
|
||||
# Les lockfiles sont versionnés : le build résout exactement les versions
|
||||
# committées au lieu d'en chercher de nouvelles à chaque build.
|
||||
COPY pubspec.yaml pubspec.lock ./
|
||||
COPY bin/pubspec.yaml bin/pubspec.lock ./bin/
|
||||
RUN flutter pub get && cd bin && dart pub get
|
||||
|
||||
# 3. Source Code Copy (CHANGES OFTEN)
|
||||
|
||||
@@ -1,282 +1,103 @@
|
||||
# XtremFlow - IPTV Web Application
|
||||
# XtremFlow — Application Web IPTV
|
||||
|
||||
High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API.
|
||||
Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, empaquetés dans une seule image Docker. Se connecte à un abonnement Xtream Codes et ajoute le magnétoscope (enregistrements planifiés, season passes), l'EPG avec repli XMLTV, et le transcodage FFmpeg à la demande.
|
||||
|
||||
## Features
|
||||
## Fonctionnalités
|
||||
|
||||
✅ **Local Authentication System**
|
||||
- Default admin user (`admin`/`admin`)
|
||||
- Secure salt-based password hashing (SHA-256)
|
||||
- No public signup - private app only
|
||||
- **Live TV, Films, Séries** : catalogues Xtream avec catégories, recherche, favoris, reprise de lecture
|
||||
- **Guide TV (EPG)** : panneau de l'abonné en priorité, repli automatique sur un dump XMLTV quand le panneau est figé
|
||||
- **Enregistrements TV** : planification depuis le guide, capture FFmpeg (`-c copy`), reprise après coupure amont ou redémarrage du serveur, fusion automatique des parties
|
||||
- **Season Passes** : enregistrement automatique de toutes les diffusions d'une émission (scan EPG toutes les 4 h)
|
||||
- **Transcodage à la demande** : `source | high | medium | low` (live et VOD), `source` = zéro transcodage ; NVENC optionnel
|
||||
- **Multi-utilisateurs** : comptes locaux (bcrypt), playlists par utilisateur, panneau d'administration
|
||||
|
||||
✅ **Multi-Playlist Management**
|
||||
- Centralized Xtream credentials management
|
||||
- Playlist assignment to users
|
||||
- Easy switching between playlists
|
||||
## Stack
|
||||
|
||||
✅ **High-Performance Dashboard (60fps)**
|
||||
- Category-based pagination (100 items/page for Live TV, 50 for Movies)
|
||||
- Lazy loading with `ListView.builder` / `GridView.builder`
|
||||
- Image caching with `cached_network_image`
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) |
|
||||
| Backend | Dart compilé en natif (`dart compile exe`), shelf |
|
||||
| Base | SQLite côté serveur (`/app/data/xtremflow.db`) |
|
||||
| Capture/Transcodage | FFmpeg (build BtbN, NVENC inclus) |
|
||||
| Conteneur | Debian slim multi-stage, port **8089** |
|
||||
|
||||
✅ **Live TV with EPG**
|
||||
- Electronic Program Guide (EPG) overlay
|
||||
- "Now & Next" program display
|
||||
- Real-time progress bar
|
||||
|
||||
✅ **VOD & Series**
|
||||
- Movies and Series organized by categories
|
||||
- Grid layout with posters
|
||||
- Optimized ratings display (1 decimal place)
|
||||
|
||||
✅ **Docker Deployment**
|
||||
- Multi-stage build with Flutter and Dart
|
||||
- Custom Dart Server (`bin/server.dart`)
|
||||
- **FFmpeg Transcoding** for mobile compatibility
|
||||
- **Cache Management** system for temporary files
|
||||
- External network support (`nginx_default`)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Flutter Web
|
||||
- **State Management**: Riverpod
|
||||
- **Local Database**: Hive (Web IndexedDB) with AES encryption
|
||||
- **Networking**: Dio with cache interceptors
|
||||
- **Routing**: GoRouter with auth guards
|
||||
- **Video Player**: `video_player` + `chewie`
|
||||
- **UI**: Google Fonts, Material Design 3
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Existing `nginx_default` network (for reverse proxy routing)
|
||||
- Flutter SDK (for local development only)
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
### 1. Build the Docker image
|
||||
## Démarrage rapide (Docker)
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose up -d --build
|
||||
# Application sur http://localhost:8089
|
||||
```
|
||||
|
||||
### 2. Start the container
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 3. Access via reverse proxy
|
||||
|
||||
Configure your reverse proxy (Nginx/Traefik) to route traffic to:
|
||||
- **Container**: `xtremflow`
|
||||
- **Internal Port**: `8080`
|
||||
- **Network**: `nginx_default`
|
||||
|
||||
Example Nginx configuration:
|
||||
|
||||
```nginx
|
||||
location /iptv {
|
||||
proxy_pass http://xtremflow:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Login
|
||||
|
||||
- **URL**: `http://your-domain/iptv`
|
||||
- **Default Credentials**:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
⚠️ **Change the admin password immediately after first login!**
|
||||
|
||||
## Local Development
|
||||
|
||||
### Install dependencies
|
||||
Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléatoire affiché une seule fois dans les logs** (`docker logs xtremflow`), sauf si `ADMIN_INITIAL_PASSWORD` est défini. Changez-le après la première connexion.
|
||||
|
||||
### Variables d'environnement (docker-compose.yml)
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
||||
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
||||
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
|
||||
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
|
||||
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
|
||||
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
|
||||
| `RECORDINGS_QUOTA_GB` | `0` (off) | Quota du dossier d'enregistrements (rotation des plus anciens terminés) |
|
||||
| `TRUSTED_PROXIES` | loopback + RFC1918 | IPs de reverse proxy dont `X-Forwarded-For` est honoré |
|
||||
|
||||
## Développement local
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
flutter pub get
|
||||
flutter analyze && flutter test
|
||||
flutter run -d chrome # nécessite le backend lancé pour les routes /api
|
||||
|
||||
# Backend
|
||||
cd bin
|
||||
dart pub get
|
||||
dart analyze && dart test
|
||||
dart run server.dart --port 8089 --path ../build/web
|
||||
```
|
||||
|
||||
### Generate Hive adapters (if modified)
|
||||
Le build Windows natif est documenté dans `BUILD.md`.
|
||||
|
||||
```bash
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
### Run web app
|
||||
|
||||
```bash
|
||||
flutter run -d chrome
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── database/
|
||||
│ │ └── hive_service.dart # Hive initialization & encryption
|
||||
│ ├── models/
|
||||
│ │ ├── app_user.dart # User model (Hive)
|
||||
│ │ ├── playlist_config.dart # Playlist credentials (Hive)
|
||||
│ │ └── iptv_models.dart # Channel, VOD, Series, EPG models
|
||||
│ ├── router/
|
||||
│ │ └── app_router.dart # GoRouter configuration
|
||||
│ └── utils/
|
||||
│ └── crypto_utils.dart # Password hashing utilities
|
||||
├── features/
|
||||
│ ├── auth/
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── auth_provider.dart # Authentication state
|
||||
│ │ └── screens/
|
||||
│ │ └── login_screen.dart
|
||||
│ ├── admin/
|
||||
│ │ └── screens/
|
||||
│ │ └── admin_panel.dart # User & Playlist CRUD
|
||||
│ └── iptv/
|
||||
│ ├── services/
|
||||
│ │ └── xtream_service.dart # Xtream API client
|
||||
│ ├── providers/
|
||||
│ │ └── xtream_provider.dart # Riverpod providers
|
||||
│ ├── screens/
|
||||
│ │ └── player_screen.dart # Video player
|
||||
│ └── widgets/
|
||||
│ ├── live_tv_tab.dart # Live TV with pagination
|
||||
│ ├── movies_tab.dart # Movies grid
|
||||
│ ├── series_tab.dart # Series grid
|
||||
│ └── epg_overlay.dart # EPG display
|
||||
└── main.dart
|
||||
bin/ Serveur Dart
|
||||
├── server.dart Point d'entrée, routage, arrêt gracieux
|
||||
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…)
|
||||
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV
|
||||
├── database/ SQLite (schéma, migrations)
|
||||
├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés)
|
||||
└── test/ Tests backend (dart test)
|
||||
|
||||
lib/ Frontend Flutter
|
||||
├── core/ Modèles, thème, router, clients API
|
||||
├── features/ auth / admin / iptv (desktop)
|
||||
└── mobile/ Variantes d'écrans mobiles
|
||||
|
||||
web/ Players HTML + libs vendorisées (hls.js, mpegts.js)
|
||||
```
|
||||
|
||||
## Security Features
|
||||
## Sécurité
|
||||
|
||||
### Password Storage
|
||||
- **Algorithm**: SHA-256 with random UUID-based salt
|
||||
- **Format**: `salt:hash` (stored in Hive)
|
||||
- **Legacy Support**: Fallback to unsalted comparison for migration
|
||||
- Mots de passe **bcrypt** (migration lazy depuis les anciens hashes au login)
|
||||
- Les credentials Xtream ne quittent jamais le serveur : passerelle `/api/xtream-api` et proxy `/api/xtream` (authentifié par session) avec injection côté serveur
|
||||
- Redaction des credentials dans tous les logs ; anti-SSRF avec revalidation des redirections
|
||||
- Cookie de session HttpOnly ; rate limiting global + limite de tentatives de login par IP
|
||||
|
||||
### Database Encryption
|
||||
- **Hive AES Cipher** (256-bit key)
|
||||
- Key stored in `FlutterSecureStorage`
|
||||
- Automatic key generation on first run
|
||||
## CI / Publication
|
||||
|
||||
### Authentication Flow
|
||||
1. User enters credentials
|
||||
2. System retrieves stored hash
|
||||
3. Input password is hashed with same salt
|
||||
4. Constant-time comparison prevents timing attacks
|
||||
`ci.yml` (analyze + tests + build web/backend) tourne sur chaque PR et push main ; `docker-publish.yml` publie `ghcr.io/r0m1k3/xtremflow:latest` **uniquement après une CI verte** sur main.
|
||||
|
||||
## Performance Optimizations
|
||||
## Dépannage
|
||||
|
||||
### Memory Management (20k+ channels)
|
||||
- **Grouping**: Channels organized by category
|
||||
- **Pagination**: 100 items per page (Live TV), 50 per page (Movies)
|
||||
- **Lazy Loading**: Only render visible items
|
||||
- **Image Caching**: Disk/memory cache with `cached_network_image`
|
||||
- **Logs** : `docker logs xtremflow` (les URLs y sont redactées)
|
||||
- **Mot de passe admin perdu** : supprimer le volume `xtremflow-data` recrée la base et affiche un nouveau mot de passe (⚠ efface utilisateurs et historique d'enregistrements)
|
||||
- **EPG vide** : vérifier `EPG_XMLTV_URLS` et que la chaîne a un `epg_channel_id` ; l'en-tête `X-Epg-Source` des réponses `/api/epg/<id>` indique la source utilisée
|
||||
- **Enregistrement échoué** : bouton « Logs » sur la ligne de l'enregistrement ; la raison (`error_reason`) est affichée sous le titre
|
||||
|
||||
### Network Optimization
|
||||
- **Dio Cache Interceptor**: 1-hour cache for API responses
|
||||
- **EPG Cache**: 5-minute refresh for program data
|
||||
- **Hive Disk Store**: Persistent cache across sessions
|
||||
## Licence
|
||||
|
||||
### Rendering (60fps Target)
|
||||
- `ListView.builder` with fixed `itemExtent`
|
||||
- `AutomaticKeepAliveClientMixin` for tab state
|
||||
- Expansion panels for category navigation
|
||||
- Grid with fixed `crossAxisCount` and `childAspectRatio`
|
||||
|
||||
## Xtream API Integration
|
||||
|
||||
### Supported Endpoints
|
||||
|
||||
| Endpoint | Purpose | Caching |
|
||||
|----------|---------|---------|
|
||||
| `player_api.php` | Authentication | 1 hour |
|
||||
| `get_live_streams` | Live TV channels | 1 hour |
|
||||
| `get_vod_streams` | Movies | 1 hour |
|
||||
| `get_series` | Series | 1 hour |
|
||||
| `get_short_epg` | EPG data | 5 minutes |
|
||||
|
||||
### Stream URL Formats
|
||||
|
||||
```dart
|
||||
// Live TV
|
||||
http://[dns]/live/[username]/[password]/[stream_id].m3u8
|
||||
|
||||
// Movies
|
||||
http://[dns]/movie/[username]/[password]/[stream_id].[container_extension]
|
||||
|
||||
// Series
|
||||
http://[dns]/series/[username]/[password]/[stream_id].[container_extension]
|
||||
```
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### Dockerfile (Multi-Stage)
|
||||
|
||||
**Stage 1: Builder**
|
||||
- Base: `cirrusci/flutter:stable`
|
||||
- Build: `flutter build web --release --web-renderer html`
|
||||
|
||||
**Stage 2: Runtime**
|
||||
- Base: `dart:stable`
|
||||
- Server: `dhttpd --host 0.0.0.0 --port 8080`
|
||||
- Size: ~150MB (compressed)
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
iptv-web:
|
||||
build: .
|
||||
container_name: xtremflow
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nginx_default
|
||||
|
||||
networks:
|
||||
nginx_default:
|
||||
external: true
|
||||
```
|
||||
|
||||
**No port mapping** - Access via reverse proxy only.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs xtremflow
|
||||
|
||||
# Verify network exists
|
||||
docker network ls | grep nginx_default
|
||||
|
||||
# Create network if missing
|
||||
docker network create nginx_default
|
||||
```
|
||||
|
||||
### Login fails with admin/admin
|
||||
- Check Hive database initialization in logs
|
||||
- Verify `HiveService.init()` completed successfully
|
||||
- Default admin is seeded only if `users` box is empty
|
||||
|
||||
### EPG not displaying
|
||||
- EPG is optional and gracefully degrades
|
||||
- Check if Xtream server supports `get_short_epg`
|
||||
- Verify stream has `epg_channel_id`
|
||||
|
||||
### Performance issues (FPS drops)
|
||||
- Reduce `_itemsPerPage` constant (currently 100 for Live TV)
|
||||
- Disable image caching temporarily
|
||||
- Check browser DevTools Performance tab
|
||||
|
||||
## License
|
||||
|
||||
Proprietary - Private Use Only
|
||||
|
||||
## Support
|
||||
|
||||
For Xtream API documentation, consult your IPTV provider.
|
||||
Propriétaire — usage privé uniquement.
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Réponse pour une image indisponible.
|
||||
///
|
||||
/// Statut 410 et non 404 : la `Cascade` du serveur retombe sur le handler
|
||||
/// suivant pour tout 404 et renvoie finalement celui du handler statique, sans
|
||||
/// notre `cache-control`. Un 410 s'arrête ici.
|
||||
///
|
||||
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
|
||||
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
|
||||
/// est essentiel : sans lui le navigateur redemande le logo à chaque
|
||||
/// affichage de la grille et le flot de requêtes mortes reprend aussitôt.
|
||||
Response missingImageResponse() => Response(
|
||||
410,
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Mémoire courte des hôtes d'images injoignables.
|
||||
///
|
||||
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
|
||||
/// sur le panneau. Quand cette machine tombe (maintenance, DNS, quota), elle
|
||||
/// répond 5xx en quelques millisecondes, le navigateur réessaie à chaque
|
||||
/// rendu de la grille et le proxy relaie des centaines de requêtes mortes par
|
||||
/// seconde. Le serveur tournant sur un unique isolate Dart, ce flot passe
|
||||
/// devant les paquets vidéo dans la boucle d'événements : l'image saccade
|
||||
/// pendant qu'on s'acharne sur un hôte qu'on sait hors service.
|
||||
///
|
||||
/// On coupe donc court : au bout de [threshold] échecs consécutifs, l'hôte
|
||||
/// est considéré mort pendant [ttl] et les requêtes suivantes sont servies
|
||||
/// localement, sans appel sortant.
|
||||
class AssetFailureCache {
|
||||
AssetFailureCache({
|
||||
this.threshold = 3,
|
||||
this.ttl = const Duration(minutes: 5),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
/// Nombre d'échecs consécutifs avant de couper un hôte. Un 5xx passager ne
|
||||
/// doit pas priver l'utilisateur de ses logos.
|
||||
final int threshold;
|
||||
|
||||
/// Durée de la coupure. Assez courte pour qu'un hôte réparé revienne seul.
|
||||
final Duration ttl;
|
||||
|
||||
final DateTime Function() _clock;
|
||||
|
||||
final Map<String, int> _failures = {};
|
||||
final Map<String, DateTime> _downUntil = {};
|
||||
|
||||
/// L'hôte est-il réputé hors service en ce moment ?
|
||||
bool isDown(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final until = _downUntil[key];
|
||||
if (until == null) return false;
|
||||
if (_clock().isBefore(until)) return true;
|
||||
|
||||
// Coupure expirée : on repart d'une ardoise vierge pour laisser une
|
||||
// vraie chance au prochain appel.
|
||||
_downUntil.remove(key);
|
||||
_failures.remove(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
void recordFailure(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final count = (_failures[key] ?? 0) + 1;
|
||||
_failures[key] = count;
|
||||
if (count >= threshold) {
|
||||
_downUntil[key] = _clock().add(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
void recordSuccess(String host) {
|
||||
final key = host.toLowerCase();
|
||||
_failures.remove(key);
|
||||
_downUntil.remove(key);
|
||||
}
|
||||
}
|
||||
+231
-65
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
@@ -8,10 +9,12 @@ import '../utils/log_redactor.dart';
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
///
|
||||
/// Le panneau de l'abonné reste la source de référence. Une source XMLTV
|
||||
/// externe n'est interrogée qu'en dernier recours, quand le panneau ne rend
|
||||
/// aucun programme couvrant l'instant présent ou à venir — cas fréquent des
|
||||
/// revendeurs dont le guide est figé depuis plusieurs jours.
|
||||
/// Le panneau de l'abonné reste la source de référence, mais par son dump
|
||||
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
||||
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
||||
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
||||
/// en dernier recours — cas des revendeurs dont le guide est figé depuis
|
||||
/// plusieurs jours.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
@@ -31,7 +34,43 @@ class EpgApi {
|
||||
/// pas par `stream_id` propre au panneau.
|
||||
final Map<String, _ChannelMap> _channelMaps = {};
|
||||
|
||||
EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv;
|
||||
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
||||
final http.Client _http;
|
||||
|
||||
/// Fabrique de la source XMLTV du panneau, une par compte.
|
||||
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
||||
|
||||
/// Index XMLTV du panneau, par compte.
|
||||
final Map<String, XmltvEpgService> _panelXmltv = {};
|
||||
|
||||
EpgApi(
|
||||
this._getPlaylist, {
|
||||
XmltvEpgService? xmltv,
|
||||
http.Client? httpClient,
|
||||
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
||||
}) : _xmltv = xmltv,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
||||
|
||||
/// Dump XMLTV servi par le panneau lui-même.
|
||||
///
|
||||
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
||||
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
||||
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
||||
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
||||
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
||||
/// clients IPTV rapides.
|
||||
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
||||
final user = Uri.encodeQueryComponent(playlist.username);
|
||||
final password = Uri.encodeQueryComponent(playlist.password);
|
||||
return XmltvEpgService(
|
||||
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
||||
refreshInterval: const Duration(hours: 3),
|
||||
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
||||
// plusieurs minutes sur un panneau qui traîne.
|
||||
downloadTimeout: const Duration(seconds: 90),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
@@ -52,56 +91,61 @@ class EpgApi {
|
||||
}
|
||||
|
||||
try {
|
||||
final dns = playlist.dns;
|
||||
Map<String, dynamic> epgData = {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
};
|
||||
|
||||
// Ordre d'essai des actions Xtream.
|
||||
// Sources par ordre de préférence. On s'arrête à la première qui
|
||||
// contient un programme en cours ou à venir ; à défaut, la première
|
||||
// non vide sert de repli.
|
||||
//
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = [
|
||||
'get_simple_data_table',
|
||||
'get_short_epg',
|
||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
||||
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||
// secondes pièce.
|
||||
//
|
||||
// Le dump externe passe ensuite AVANT l'interrogation chaîne par
|
||||
// chaîne : il est déjà indexé en mémoire (réponse instantanée), alors
|
||||
// que `player_api` coûte plusieurs secondes par chaîne et échouait en
|
||||
// prod — ce qui laissait toute la grille sans guide.
|
||||
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||
];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
try {
|
||||
epgData = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if ((epgData['programmes'] as List).isNotEmpty) break;
|
||||
}
|
||||
|
||||
var epgData = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': <Map<String, dynamic>>[],
|
||||
};
|
||||
var source = 'xtream';
|
||||
if (!_hasCurrentProgramme(epgData)) {
|
||||
final fallback = await _xmltvProgrammes(playlist, channelId);
|
||||
if (fallback.isNotEmpty) {
|
||||
epgData = {'channel_id': channelId, 'programmes': fallback};
|
||||
source = 'xmltv';
|
||||
var found = false;
|
||||
|
||||
for (final (name, fetch) in sources) {
|
||||
final programmes = await fetch();
|
||||
if (programmes.isEmpty) continue;
|
||||
|
||||
final candidate = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': programmes,
|
||||
};
|
||||
if (!found) {
|
||||
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
found = true;
|
||||
}
|
||||
if (_hasCurrentProgramme(candidate)) {
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final jsonStr = json.encode(epgData);
|
||||
|
||||
// Mettre en cache 30 minutes
|
||||
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
|
||||
// qui vient souvent d'une source momentanément indisponible et ne
|
||||
// doit pas priver la chaîne de guide pendant une demi-heure.
|
||||
_cache[cacheKey] = _CacheEntry(
|
||||
data: jsonStr,
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
expiresAt: DateTime.now()
|
||||
.add(Duration(minutes: found ? 30 : 5)),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
@@ -123,6 +167,68 @@ class EpgApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
||||
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final service =
|
||||
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
||||
return _fromXmltv(service, playlist, channelId);
|
||||
}
|
||||
|
||||
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
||||
///
|
||||
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
||||
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
||||
/// est absent ou ne couvre pas la chaîne.
|
||||
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = ['get_simple_data_table', 'get_short_epg'];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _http
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
} catch (e) {
|
||||
// Délai dépassé, connexion coupée… : une source parmi d'autres, pas
|
||||
// une raison de répondre 500 (ce qui privait la chaîne des sources
|
||||
// suivantes). L'exception recopie l'URL, identifiants compris.
|
||||
print('[EpgApi] player_api $action indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final programmes = (parsed['programmes'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (programmes.isNotEmpty) return programmes;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Le guide contient-il un programme en cours ou à venir ?
|
||||
///
|
||||
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
|
||||
@@ -142,8 +248,16 @@ class EpgApi {
|
||||
Future<List<Map<String, dynamic>>> _xmltvProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) =>
|
||||
_fromXmltv(_xmltv, playlist, channelId);
|
||||
|
||||
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
||||
/// source externe), ou liste vide si l'index ne la connaît pas.
|
||||
Future<List<Map<String, dynamic>>> _fromXmltv(
|
||||
XmltvEpgService? xmltv,
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final xmltv = _xmltv;
|
||||
if (xmltv == null) return const [];
|
||||
|
||||
try {
|
||||
@@ -163,46 +277,98 @@ class EpgApi {
|
||||
.map((p) => p.toJson(channelId))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
print('[EpgApi] repli XMLTV indisponible pour $channelId : $e');
|
||||
// L'exception peut recopier l'URL `player_api`/`xmltv.php`, identifiants
|
||||
// compris.
|
||||
print(
|
||||
'[EpgApi] source XMLTV indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// `stream_id` → (`epg_channel_id`, nom) depuis la réponse
|
||||
/// `get_live_streams`.
|
||||
static (Map<String, String>, Map<String, String>) _parseChannelTable(
|
||||
String body,
|
||||
) {
|
||||
final epgIds = <String, String>{};
|
||||
final names = <String, String>{};
|
||||
final decoded = json.decode(body);
|
||||
if (decoded is List) {
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) continue;
|
||||
final streamId = item['stream_id']?.toString();
|
||||
if (streamId == null || streamId.isEmpty) continue;
|
||||
final epgId = item['epg_channel_id']?.toString();
|
||||
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
|
||||
final name = item['name']?.toString();
|
||||
if (name != null && name.isNotEmpty) names[streamId] = name;
|
||||
}
|
||||
}
|
||||
return (epgIds, names);
|
||||
}
|
||||
|
||||
/// Téléchargements de la table des chaînes en cours, par compte.
|
||||
final Map<String, Future<_ChannelMap>> _channelMapsInFlight = {};
|
||||
|
||||
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
|
||||
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async {
|
||||
///
|
||||
/// Les requêtes simultanées partagent un seul téléchargement : la grille
|
||||
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
|
||||
/// son propre `get_live_streams` (7 Mo en prod).
|
||||
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final cached = _channelMaps[key];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return cached;
|
||||
return Future.value(cached);
|
||||
}
|
||||
return _channelMapsInFlight[key] ??=
|
||||
_loadChannelMap(playlist, key).whenComplete(() {
|
||||
_channelMapsInFlight.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_ChannelMap> _loadChannelMap(
|
||||
PlaylistConfig playlist,
|
||||
String key,
|
||||
) async {
|
||||
final url = '${playlist.dns}/player_api.php'
|
||||
'?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=get_live_streams';
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
http.Response? response;
|
||||
try {
|
||||
response =
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes indisponible : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
}
|
||||
|
||||
final epgIds = <String, String>{};
|
||||
final names = <String, String>{};
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is List) {
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) continue;
|
||||
final streamId = item['stream_id']?.toString();
|
||||
if (streamId == null || streamId.isEmpty) continue;
|
||||
final epgId = item['epg_channel_id']?.toString();
|
||||
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
|
||||
final name = item['name']?.toString();
|
||||
if (name != null && name.isNotEmpty) names[streamId] = name;
|
||||
}
|
||||
var epgIds = <String, String>{};
|
||||
var names = <String, String>{};
|
||||
if (response != null && response.statusCode == 200) {
|
||||
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
|
||||
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
|
||||
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
|
||||
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
|
||||
// correspondance avec le guide.
|
||||
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
|
||||
try {
|
||||
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
|
||||
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
|
||||
final map = _ChannelMap(
|
||||
epgIds: epgIds,
|
||||
names: names,
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 6)),
|
||||
expiresAt: DateTime.now().add(
|
||||
names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6),
|
||||
),
|
||||
);
|
||||
_channelMaps[key] = map;
|
||||
return map;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../services/logo_catalog.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// GET /api/logo?src=<stream_icon>&name=<nom de chaîne>
|
||||
///
|
||||
/// Logo d'une chaîne, avec repli. L'URL fournie par le panneau est tentée
|
||||
/// d'abord, via le proxy Xtream (mêmes protections anti-SSRF, même mémoire
|
||||
/// des hôtes morts). En cas d'échec, le logo est cherché par nom dans le
|
||||
/// dépôt public `tv-logos`.
|
||||
///
|
||||
/// POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
|
||||
/// perd ses logos d'un coup, alors que les autres applications IPTV les
|
||||
/// affichent encore grâce à leurs propres sources de repli.
|
||||
class LogoApi {
|
||||
LogoApi(this._proxy, this._catalog);
|
||||
|
||||
/// Proxy Xtream (`ProxyHandler.handler`), qui attend `/api/xtream/<url>`.
|
||||
final Handler _proxy;
|
||||
|
||||
final LogoCatalog _catalog;
|
||||
|
||||
/// Un logo trouvé change rarement : un jour de cache navigateur.
|
||||
static const _found = 'public, max-age=86400';
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final src = request.url.queryParameters['src'] ?? '';
|
||||
final name = request.url.queryParameters['name'] ?? '';
|
||||
|
||||
// Une URL panneau malformée ne doit pas priver la chaîne du repli.
|
||||
final proxied = src.startsWith('http://') || src.startsWith('https://')
|
||||
? Uri.tryParse('${request.requestedUri.origin}/api/xtream/$src')
|
||||
: null;
|
||||
|
||||
if (proxied != null) {
|
||||
final upstream = await _proxy(
|
||||
Request(
|
||||
'GET',
|
||||
proxied,
|
||||
headers: {
|
||||
for (final header in const ['cookie', 'authorization'])
|
||||
if (request.headers[header] != null)
|
||||
header: request.headers[header]!,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final type = upstream.headers['content-type'] ?? '';
|
||||
if (upstream.statusCode == 200 && type.startsWith('image/')) {
|
||||
return Response.ok(
|
||||
upstream.read(),
|
||||
headers: {'content-type': type, 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
// Corps d'erreur consommé : sinon la connexion amont reste ouverte.
|
||||
await upstream.read().drain<void>();
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
final bytes = await _catalog.logoFor(name);
|
||||
if (bytes != null) {
|
||||
return Response.ok(
|
||||
bytes,
|
||||
headers: {'content-type': 'image/png', 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return missingImageResponse();
|
||||
}
|
||||
}
|
||||
+48
-17
@@ -7,6 +7,22 @@ import '../database/database.dart';
|
||||
import '../middleware/auth_middleware.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// L'URL désigne-t-elle une image ou un logo ?
|
||||
///
|
||||
/// Ces URL échappent à l'allowlist de domaine (les revendeurs hébergent leurs
|
||||
/// picons ailleurs que le panneau) et méritent un repli visuel plutôt qu'une
|
||||
/// erreur propagée au navigateur.
|
||||
bool _isStaticAssetUrl(Uri url) =>
|
||||
url.path.endsWith('.png') ||
|
||||
url.path.endsWith('.jpg') ||
|
||||
url.path.endsWith('.jpeg') ||
|
||||
url.path.endsWith('.gif') ||
|
||||
url.path.endsWith('.webp') ||
|
||||
url.path.endsWith('.ico') ||
|
||||
url.path.contains('/picons/') ||
|
||||
url.path.contains('/logos/');
|
||||
|
||||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||||
@@ -39,6 +55,9 @@ class ProxyHandler {
|
||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||
static const _cacheDuration = Duration(minutes: 5);
|
||||
|
||||
/// Hôtes d'images réputés hors service (voir [AssetFailureCache]).
|
||||
final AssetFailureCache _assetFailures = AssetFailureCache();
|
||||
|
||||
static const _allowedHeaders = [
|
||||
'content-type',
|
||||
'content-range',
|
||||
@@ -141,14 +160,14 @@ class ProxyHandler {
|
||||
|
||||
// SSRF Protection - but allow images/static assets from any host
|
||||
// Xtream providers often use separate CDN servers for picons/images
|
||||
final isStaticAsset = targetUrl.path.endsWith('.png') ||
|
||||
targetUrl.path.endsWith('.jpg') ||
|
||||
targetUrl.path.endsWith('.jpeg') ||
|
||||
targetUrl.path.endsWith('.gif') ||
|
||||
targetUrl.path.endsWith('.webp') ||
|
||||
targetUrl.path.endsWith('.ico') ||
|
||||
targetUrl.path.contains('/picons/') ||
|
||||
targetUrl.path.contains('/logos/');
|
||||
final isStaticAsset = _isStaticAssetUrl(targetUrl);
|
||||
|
||||
// Hôte d'images déjà constaté mort : on répond tout de suite.
|
||||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||||
// flot qu'on cherche à éteindre.
|
||||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
String? allowedHost;
|
||||
if (!isStaticAsset) {
|
||||
@@ -251,6 +270,23 @@ class ProxyHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||||
// rendu. On répond un 404 mis en cache, et on compte l'échec.
|
||||
if (isStaticAsset) {
|
||||
if (response.statusCode >= 400) {
|
||||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||||
// redirection : c'est cette clé-là que consulte le garde-fou
|
||||
// en tête de requête.
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||||
// reste ouverte jusqu'au timeout.
|
||||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||||
return missingImageResponse();
|
||||
}
|
||||
_assetFailures.recordSuccess(targetUrl.host);
|
||||
}
|
||||
|
||||
// Build response headers from source response
|
||||
final responseHeaders = <String, String>{
|
||||
'access-control-allow-origin': '*',
|
||||
@@ -282,15 +318,10 @@ class ProxyHandler {
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Return transparent 1x1 pixel image fallback for images
|
||||
if (targetUrl?.path.endsWith('.png') == true ||
|
||||
targetUrl?.path.endsWith('.jpg') == true) {
|
||||
return Response.ok(
|
||||
base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
),
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
// Image injoignable : 404 mis en cache plutôt qu'une erreur 500
|
||||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
@@ -74,18 +75,47 @@ class RecordingsApi {
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Response handleGetAll(Request request) {
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminée.
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
|
||||
+430
-44
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
@@ -6,7 +5,12 @@ import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/ffmpeg_session_manager.dart';
|
||||
import '../services/gpu_fallback.dart';
|
||||
import '../services/upstream_slots.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
final Directory _hlsTempDir =
|
||||
@@ -15,6 +19,64 @@ final Directory _hlsTempDir =
|
||||
/// Global FFmpeg session registry (initialized in [initStreaming]).
|
||||
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
|
||||
|
||||
/// Pannes GPU récentes, partagées par toutes les routes de transcodage.
|
||||
final GpuHealth _gpuHealth = GpuHealth();
|
||||
|
||||
/// Connexions de lecture ouvertes chez le fournisseur, par compte.
|
||||
final UpstreamSlots _upstreamSlots = UpstreamSlots();
|
||||
|
||||
/// Numérote les flux `turbo.ts` (un processus FFmpeg par requête).
|
||||
int _turboCounter = 0;
|
||||
|
||||
/// Quota `max_connections` de chaque compte Xtream.
|
||||
final AccountLimits _accountLimits = AccountLimits();
|
||||
|
||||
/// Fait de la place chez le fournisseur avant d'ouvrir la connexion [id].
|
||||
///
|
||||
/// Voir [UpstreamSlots] : sur un compte à une connexion, le flux précédent
|
||||
/// (souvent un FFmpeg orphelin que plus personne ne regarde) bloquait le
|
||||
/// suivant.
|
||||
Future<void> _claimUpstream(PlaylistConfig playlist, String id) async {
|
||||
final max = await _accountLimits.maxFor(playlist);
|
||||
final freed =
|
||||
_upstreamSlots.makeRoom(accountKeyOf(playlist), max: max, keep: id);
|
||||
if (freed.isEmpty) return;
|
||||
print('[Upstream] $id : ${freed.join(', ')} coupé(s) '
|
||||
'(quota fournisseur : $max connexion(s))');
|
||||
// Laisser le panneau constater la fermeture : rouvrir aussitôt se fait
|
||||
// refuser sur les comptes à une seule connexion.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin.
|
||||
///
|
||||
/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux
|
||||
/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau
|
||||
/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes.
|
||||
const _viewerGrace = Duration(seconds: 15);
|
||||
|
||||
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
|
||||
///
|
||||
/// [lastActivity] : dernier signe de vie du spectateur ; la connexion n'est
|
||||
/// coupée pour en ouvrir une autre que s'il remonte à plus de
|
||||
/// [_viewerGrace].
|
||||
void _registerUpstream(
|
||||
PlaylistConfig playlist,
|
||||
String id,
|
||||
Process process, {
|
||||
required void Function() release,
|
||||
required DateTime Function() lastActivity,
|
||||
}) {
|
||||
final token = _upstreamSlots.register(
|
||||
id: id,
|
||||
account: accountKeyOf(playlist),
|
||||
release: release,
|
||||
isActive: () =>
|
||||
DateTime.now().difference(lastActivity()) < _viewerGrace,
|
||||
);
|
||||
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
|
||||
}
|
||||
|
||||
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
|
||||
String _getFFmpegPath() {
|
||||
if (Platform.isWindows) {
|
||||
@@ -51,6 +113,16 @@ String _sanitizeQuality(String? quality) {
|
||||
|
||||
bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id);
|
||||
|
||||
/// Extension de conteneur d'un film/épisode, validée (elle finit dans l'URL
|
||||
/// du panneau). `mkv` par défaut : c'était l'unique valeur jusqu'ici, les
|
||||
/// anciens clients qui n'envoient pas `ext` gardent leur comportement.
|
||||
String vodContainerExtension(String? ext) {
|
||||
final value = ext?.toLowerCase();
|
||||
return value != null && RegExp(r'^[a-z0-9]{2,5}$').hasMatch(value)
|
||||
? value
|
||||
: 'mkv';
|
||||
}
|
||||
|
||||
/// Video encoding args for the requested quality (live profile).
|
||||
List<String> _liveVideoArgs(String quality, bool gpu) {
|
||||
switch (quality) {
|
||||
@@ -94,7 +166,11 @@ List<String> _liveVideoArgs(String quality, bool gpu) {
|
||||
'-g', '50',
|
||||
]
|
||||
: [
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency',
|
||||
// veryfast au lieu de medium : en live, l'encodeur doit tenir
|
||||
// le temps réel ET produire le premier segment vite ; medium
|
||||
// ajoutait plusieurs secondes au zap sans gain visible à ce
|
||||
// débit.
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||
'-profile:v', 'high', '-level', '4.0',
|
||||
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
||||
'-pix_fmt', 'yuv420p', '-g', '50',
|
||||
@@ -175,6 +251,91 @@ Map<String, String> _segmentHeaders({int maxAge = 60}) => {
|
||||
'Cache-Control': 'max-age=$maxAge',
|
||||
};
|
||||
|
||||
typedef _SessionAttempt = ({FfmpegSession? session, bool ready, String? error});
|
||||
|
||||
/// Démarre (ou récupère) la session [id] et attend sa playlist.
|
||||
///
|
||||
/// Un `Process.start` qui échoue (serveur à court de processus ou de
|
||||
/// mémoire) devient un échec ordinaire au lieu d'une exception non
|
||||
/// rattrapée qui remontait en 500.
|
||||
///
|
||||
/// [upstream] : playlist dont la session ouvre une connexion chez le
|
||||
/// fournisseur (direct, film). Null pour un enregistrement, lu sur disque.
|
||||
Future<_SessionAttempt> _runSession({
|
||||
required String id,
|
||||
required bool isLive,
|
||||
required List<String> Function(Directory dir) argsBuilder,
|
||||
int minSegments = 1,
|
||||
PlaylistConfig? upstream,
|
||||
}) async {
|
||||
final starting = sessionManager.needsStart(id, isLive: isLive);
|
||||
if (upstream != null && starting) await _claimUpstream(upstream, id);
|
||||
|
||||
final FfmpegSession session;
|
||||
try {
|
||||
session = await sessionManager.getOrStart(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: argsBuilder,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
return (session: null, ready: false, error: 'FFmpeg start failed: $e');
|
||||
}
|
||||
if (upstream != null && starting) {
|
||||
_registerUpstream(
|
||||
upstream,
|
||||
id,
|
||||
session.process,
|
||||
release: () => sessionManager.killSession(id),
|
||||
// Touchée à chaque requête de playlist ou de segment.
|
||||
lastActivity: () => session.lastAccess,
|
||||
);
|
||||
}
|
||||
final outcome =
|
||||
await sessionManager.waitForPlaylist(session, minSegments: minSegments);
|
||||
return (session: session, ready: outcome.ready, error: outcome.error);
|
||||
}
|
||||
|
||||
/// Comme [_runSession], avec repli sur le CPU si l'encodage GPU échoue.
|
||||
///
|
||||
/// Le repli garde le même identifiant de session : les requêtes de segments
|
||||
/// qui suivent tombent sur la session CPU sans que le lecteur s'en aperçoive.
|
||||
/// Une panne GPU fait aussi partir les sessions suivantes directement sur le
|
||||
/// CPU pendant le délai de [GpuHealth], au lieu d'échouer une à une.
|
||||
Future<_SessionAttempt> _runWithGpuFallback({
|
||||
required String id,
|
||||
required bool isLive,
|
||||
required bool wantGpu,
|
||||
required List<String> Function(bool gpu) buildArgs,
|
||||
int minSegments = 1,
|
||||
PlaylistConfig? upstream,
|
||||
}) async {
|
||||
final gpu = wantGpu && _gpuHealth.available;
|
||||
var attempt = await _runSession(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
argsBuilder: (_) => buildArgs(gpu),
|
||||
minSegments: minSegments,
|
||||
upstream: upstream,
|
||||
);
|
||||
|
||||
if (!attempt.ready && gpu && isGpuFailure(attempt.error)) {
|
||||
_gpuHealth.markFailed();
|
||||
print('[GPU] $id : encodage GPU refusé '
|
||||
'(${LogRedactor.redactUrl(attempt.error ?? '')}) — repli sur le CPU');
|
||||
sessionManager.killSession(id);
|
||||
attempt = await _runSession(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
argsBuilder: (_) => buildArgs(false),
|
||||
minSegments: minSegments,
|
||||
upstream: upstream,
|
||||
);
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy)
|
||||
// ==========================================
|
||||
@@ -232,7 +393,11 @@ Stream<List<int>> _resilientLiveBody(
|
||||
yield chunk;
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Live Proxy] $streamId : coupure amont ($e)');
|
||||
// Un `ClientException` recopie l'URL amont, identifiants compris.
|
||||
print(
|
||||
'[Live Proxy] $streamId : coupure amont '
|
||||
'(${LogRedactor.redactUrl('$e')})',
|
||||
);
|
||||
} finally {
|
||||
upstream.client.close();
|
||||
}
|
||||
@@ -304,13 +469,14 @@ Handler createLiveStreamHandler(
|
||||
);
|
||||
}
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
final result = await _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: true,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: (dir) => [
|
||||
upstream: playlist,
|
||||
wantGpu: useNvidiaGpu && quality != 'source',
|
||||
buildArgs: (gpu) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
|
||||
if (gpu) ...['-hwaccel', 'cuda'],
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
@@ -318,8 +484,13 @@ Handler createLiveStreamHandler(
|
||||
// Abort reads stuck for 30s so a stalled upstream triggers the
|
||||
// reconnect logic instead of wedging the process forever.
|
||||
'-rw_timeout', '30000000',
|
||||
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
||||
// secondes à sonder le flux avant d'écrire le premier segment.
|
||||
'-fflags', 'nobuffer',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', targetUrl,
|
||||
..._liveVideoArgs(quality, useNvidiaGpu),
|
||||
..._liveVideoArgs(quality, gpu),
|
||||
..._audioArgs(withFilters: false),
|
||||
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
||||
// previous 20-segment window while still safe for iOS).
|
||||
@@ -336,8 +507,8 @@ Handler createLiveStreamHandler(
|
||||
],
|
||||
);
|
||||
|
||||
final result = await sessionManager.waitForPlaylist(session);
|
||||
if (!result.ready) {
|
||||
final session = result.session;
|
||||
if (!result.ready || session == null) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502, body: 'Live transcoder failed: ${result.error}');
|
||||
}
|
||||
@@ -364,6 +535,110 @@ Handler createLiveStreamHandler(
|
||||
return Response.found('/api/live/$streamId/$quality/playlist.m3u8');
|
||||
});
|
||||
|
||||
// Route: /api/live/{streamId}/turbo.ts — flux MPEG-TS continu pour le
|
||||
// player web (mpegts.js) : vidéo copiée telle quelle, audio TOUJOURS
|
||||
// réencodé en AAC.
|
||||
//
|
||||
// POURQUOI : mpegts.js ne démuxe que l'AAC et le MP3. Les chaînes qui
|
||||
// diffusent en AC-3/E-AC-3 (0x81/0x87) ou en MPEG-1 Layer II passaient par
|
||||
// le proxy brut → image sans aucun son. Le réencodage audio seul coûte
|
||||
// quelques % de CPU (pas de transcodage vidéo) et garde le zapping
|
||||
// instantané : un seul flux continu, pas de segmentation HLS à amorcer.
|
||||
//
|
||||
// Latence : -fflags nobuffer + probesize réduit → première image en
|
||||
// ~0,5-1,5 s au lieu des 2-6 s d'analyse par défaut de FFmpeg.
|
||||
router.get('/<streamId>/turbo.ts', (Request request, String streamId) async {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid stream ID');
|
||||
}
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null) return Response.forbidden('No playlist');
|
||||
|
||||
final targetUrl =
|
||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
||||
print(
|
||||
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||
);
|
||||
|
||||
// Un identifiant par requête : deux onglets sur la même chaîne sont
|
||||
// deux connexions distinctes chez le fournisseur.
|
||||
final upstreamId = 'turbo_${streamId}_${++_turboCounter}';
|
||||
await _claimUpstream(playlist, upstreamId);
|
||||
|
||||
final Process process;
|
||||
try {
|
||||
process = await Process.start(_getFFmpegPath(), [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_delay_max', '10',
|
||||
'-rw_timeout', '30000000',
|
||||
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', targetUrl,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
||||
'-af', 'aresample=async=1',
|
||||
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
||||
'-muxdelay', '0', '-muxpreload', '0',
|
||||
'-f', 'mpegts', 'pipe:1',
|
||||
]);
|
||||
} on ProcessException catch (e) {
|
||||
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
||||
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
||||
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
||||
'(${LogRedactor.redactUrl('$e')})');
|
||||
return Response(503, body: 'FFmpeg start failed');
|
||||
}
|
||||
|
||||
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
||||
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
||||
// reçoit plus, la contre-pression bloquant FFmpeg.
|
||||
var lastDelivered = DateTime.now();
|
||||
_registerUpstream(
|
||||
playlist,
|
||||
upstreamId,
|
||||
process,
|
||||
release: () => process.kill(ProcessSignal.sigterm),
|
||||
lastActivity: () => lastDelivered,
|
||||
);
|
||||
|
||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
print('[Live Turbo] $streamId ffmpeg: '
|
||||
'${LogRedactor.redactUrl(trimmed)}');
|
||||
}
|
||||
});
|
||||
|
||||
// Relayer stdout vers le client en gardant la contre-pression, et tuer
|
||||
// FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus
|
||||
// s'accumulent à chaque zap).
|
||||
final body = pipeWithBackpressure(
|
||||
process.stdout,
|
||||
onStop: () => process.kill(ProcessSignal.sigterm),
|
||||
).map((chunk) {
|
||||
lastDelivered = DateTime.now();
|
||||
return chunk;
|
||||
});
|
||||
|
||||
return Response(
|
||||
200,
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Cache-Control': 'no-store',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Route: /api/live/{streamId}.ts (Direct Proxy for recordings/raw playback)
|
||||
router.get('/<streamId>.ts', (Request request, String streamId) async {
|
||||
final playlist = await getPlaylist(request);
|
||||
@@ -439,8 +714,11 @@ Handler createVodStreamHandler(
|
||||
final contentType = request.url.queryParameters['type'] ?? 'movie';
|
||||
final basePath = contentType == 'series' ? 'series' : 'movie';
|
||||
|
||||
// Extension réelle du fichier (`container_extension` du catalogue) : le
|
||||
// panneau refuse un `.mkv` imposé à un film stocké en `.mp4`.
|
||||
final targetUrl =
|
||||
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
|
||||
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/'
|
||||
'$streamId.${vodContainerExtension(request.url.queryParameters['ext'])}';
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final sessionId = 'vod_${streamId}_$quality';
|
||||
|
||||
@@ -450,13 +728,14 @@ Handler createVodStreamHandler(
|
||||
);
|
||||
}
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
final result = await _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: (dir) => [
|
||||
upstream: playlist,
|
||||
wantGpu: useNvidiaGpu && quality != 'source',
|
||||
buildArgs: (gpu) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
|
||||
if (gpu) ...['-hwaccel', 'cuda'],
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
@@ -464,10 +743,13 @@ Handler createVodStreamHandler(
|
||||
'-reconnect_delay_max', '10',
|
||||
'-rw_timeout', '30000000',
|
||||
'-timeout', '30000000',
|
||||
'-analyzeduration', '5000000',
|
||||
'-probesize', '10000000',
|
||||
// Sonde réduite (10 Mo → 5 Mo) : sur un upstream lent, télécharger
|
||||
// 10 Mo avant la première image ajoutait plusieurs secondes au
|
||||
// démarrage de chaque film.
|
||||
'-analyzeduration', '2000000',
|
||||
'-probesize', '5000000',
|
||||
'-i', targetUrl,
|
||||
..._vodVideoArgs(quality, useNvidiaGpu),
|
||||
..._vodVideoArgs(quality, gpu),
|
||||
..._audioArgs(withFilters: quality != 'source'),
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
@@ -482,8 +764,8 @@ Handler createVodStreamHandler(
|
||||
],
|
||||
);
|
||||
|
||||
final result = await sessionManager.waitForPlaylist(session);
|
||||
if (!result.ready) {
|
||||
final session = result.session;
|
||||
if (!result.ready || session == null) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502, body: 'VOD transcoder failed: ${result.error}');
|
||||
}
|
||||
@@ -506,10 +788,15 @@ Handler createVodStreamHandler(
|
||||
(Request request, String streamId) async {
|
||||
final quality =
|
||||
_sanitizeQuality(request.url.queryParameters['quality']);
|
||||
final query = request.url.queryParameters['type'] != null
|
||||
? '?type=${request.url.queryParameters['type']}'
|
||||
: '';
|
||||
return Response.found('/api/vod/$streamId/$quality/playlist.m3u8$query');
|
||||
final params = request.url.queryParameters;
|
||||
final query = Uri(queryParameters: {
|
||||
if (params['type'] != null) 'type': params['type']!,
|
||||
if (params['ext'] != null) 'ext': params['ext']!,
|
||||
}).query;
|
||||
return Response.found(
|
||||
'/api/vod/$streamId/$quality/playlist.m3u8'
|
||||
'${query.isEmpty ? '' : '?$query'}',
|
||||
);
|
||||
});
|
||||
|
||||
// Route: /api/vod/{streamId}/{quality}/{segment}
|
||||
@@ -541,6 +828,10 @@ Handler createRecordingStreamHandler(
|
||||
|
||||
router.get('/<streamId>/playlist.m3u8',
|
||||
(Request request, String streamId) async {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
|
||||
final recording = db.getRecordingById(streamId);
|
||||
if (recording == null) {
|
||||
return Response.notFound('Recording not found');
|
||||
@@ -569,60 +860,155 @@ Handler createRecordingStreamHandler(
|
||||
body: 'Physical recording file not found');
|
||||
}
|
||||
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final sessionId = 'rec_$streamId';
|
||||
final start = parseRecordingStart(request.url.queryParameters['start']);
|
||||
final offsetKey = recordingOffsetKey(start);
|
||||
final sessionPrefix = 'rec_${streamId}_t';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: (dir) => [
|
||||
// La capture est faite en `-c copy` : le fichier contient le codec
|
||||
// d'origine de la chaîne, presque toujours du H.264 — directement
|
||||
// lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p,
|
||||
// si bien que la lecture démarrait collée au front d'encodage et se
|
||||
// coupait à la moindre hésitation. En copie, la segmentation va à la
|
||||
// vitesse du disque : l'enregistrement devient navigable en quelques
|
||||
// secondes. Seuls les codecs que le navigateur ne sait pas lire
|
||||
// (HEVC, MPEG-2…) justifient encore un ré-encodage.
|
||||
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
||||
final canCopyVideo = videoCodec == 'h264';
|
||||
|
||||
List<String> buildArgs({required bool copyVideo, required bool gpu}) {
|
||||
final useNvidiaGpu = !copyVideo && gpu;
|
||||
return [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||
// -ss AVANT -i : recherche rapide par index, sans décoder ce qui
|
||||
// précède. La sortie repart de 0, le lecteur ré-ajoute l'offset.
|
||||
// En copie, la position atteinte est l'image-clé qui précède.
|
||||
if (start > 0) ...['-ss', '$start'],
|
||||
'-i', targetUrl,
|
||||
if (useNvidiaGpu) ...[
|
||||
if (copyVideo) ...[
|
||||
'-c:v', 'copy',
|
||||
] else if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
|
||||
'-bufsize', '6000k',
|
||||
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
|
||||
] else ...[
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
|
||||
// veryfast/crf 20 au lieu de medium/crf 18 : à ce niveau le rendu
|
||||
// est visuellement identique, mais medium ne tenait pas le temps
|
||||
// réel en 1080p sur un CPU modeste → lecture d'enregistrement qui
|
||||
// démarre lentement puis bufferise.
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
||||
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
|
||||
'-g', '48', '-threads', '0',
|
||||
],
|
||||
..._audioArgs(withFilters: true),
|
||||
// `event` et non `vod` : en VOD, hls.js considère la playlist comme
|
||||
// définitive et ne la relit jamais. Il ne voyait donc que les
|
||||
// quelques secondes déjà transcodées à l'ouverture — d'où une durée
|
||||
// absurde et une barre de progression inutilisable. En `event`, la
|
||||
// playlist est rechargée et la zone navigable grandit avec l'encodage.
|
||||
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'vod', '-hls_allow_cache', '1',
|
||||
'-hls_playlist_type', 'event', '-hls_allow_cache', '1',
|
||||
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||
'playlist.m3u8',
|
||||
],
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
final result = await sessionManager.waitForPlaylist(session);
|
||||
if (!result.ready) {
|
||||
/// Démarre (ou récupère) la session et attend qu'elle ait de l'avance.
|
||||
///
|
||||
/// Trois segments avant de rendre la main : c'est la marge qui manquait
|
||||
/// au démarrage, la playlist étant servie dès le premier segment. En
|
||||
/// copie vidéo elle est produite en une fraction de seconde, elle ne
|
||||
/// coûte donc que sur un ré-encodage.
|
||||
Future<_SessionAttempt> run(bool copyVideo) {
|
||||
if (!sessionManager.contains(sessionId)) {
|
||||
print(
|
||||
'[Recording] $sessionId : '
|
||||
'${copyVideo ? 'copie vidéo' : 'ré-encodage'} '
|
||||
'(codec source : ${videoCodec ?? 'inconnu'})',
|
||||
);
|
||||
}
|
||||
if (copyVideo) {
|
||||
return _runSession(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
argsBuilder: (_) => buildArgs(copyVideo: true, gpu: false),
|
||||
minSegments: 3,
|
||||
);
|
||||
}
|
||||
return _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(),
|
||||
buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu),
|
||||
minSegments: 3,
|
||||
);
|
||||
}
|
||||
|
||||
var attempt = await run(canCopyVideo);
|
||||
|
||||
// La copie a échoué : certains conteneurs ou horodatages ne s'y prêtent
|
||||
// pas. Plutôt qu'un écran d'erreur, on retombe sur le ré-encodage, qui
|
||||
// était le seul chemin jusqu'ici.
|
||||
if (!attempt.ready && canCopyVideo) {
|
||||
print(
|
||||
'[Recording] $sessionId : copie vidéo refusée (${attempt.error}) '
|
||||
'— repli sur le ré-encodage',
|
||||
);
|
||||
sessionManager.killSession(sessionId);
|
||||
attempt = await run(false);
|
||||
}
|
||||
|
||||
final session = attempt.session;
|
||||
if (!attempt.ready || session == null) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502,
|
||||
body: 'Recording transcoder failed: ${result.error}');
|
||||
body: 'Recording transcoder failed: ${attempt.error}');
|
||||
}
|
||||
|
||||
session.touch();
|
||||
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
||||
// progression), une fois la nouvelle session confirmée démarrée.
|
||||
sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId);
|
||||
|
||||
// Les segments sont préfixés par l'offset pour rester rattachés à LEUR
|
||||
// session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à
|
||||
// 45 min sont deux fichiers différents.
|
||||
final playlist = await File('${session.dir.path}/playlist.m3u8')
|
||||
.readAsString();
|
||||
|
||||
return Response.ok(
|
||||
File('${session.dir.path}/playlist.m3u8').openRead(),
|
||||
rewriteRecordingPlaylist(playlist, offsetKey),
|
||||
headers: _hlsHeaders(),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) async {
|
||||
if (segment.contains('..')) {
|
||||
Response serveSegment(String streamId, String offsetKey, String segment) {
|
||||
if (!_isValidStreamId(streamId) ||
|
||||
parseRecordingOffsetKey(offsetKey) == null ||
|
||||
segment.contains('..') ||
|
||||
segment.contains('/')) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
final sessionId = 'rec_$streamId';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
sessionManager.touch(sessionId);
|
||||
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
|
||||
}
|
||||
|
||||
router.get('/<streamId>/<offsetKey>/<segment>',
|
||||
(Request request, String streamId, String offsetKey, String segment) {
|
||||
return serveSegment(streamId, offsetKey, segment);
|
||||
});
|
||||
|
||||
// Compat : playlist servie avant la mise en place des offsets, encore en
|
||||
// cache dans un onglet ouvert.
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) {
|
||||
return serveSegment(streamId, recordingOffsetKey(0), segment);
|
||||
});
|
||||
|
||||
return router.call;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
|
||||
/// fournisseur, en `.ts` et en `.m3u8`.
|
||||
///
|
||||
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
|
||||
/// du panneau (mesuré en prod). Le panneau autorise aussi le HLS ; reste à
|
||||
/// savoir lequel des deux formats démarre le plus vite, et où part le temps
|
||||
/// (redirection, en-têtes, premier octet). Les identifiants n'apparaissent
|
||||
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
|
||||
Future<Map<String, Object?>> probeUpstream(
|
||||
PlaylistConfig playlist,
|
||||
String streamId, {
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final c = client ?? http.Client();
|
||||
try {
|
||||
final base = '${playlist.dns}/live/${playlist.username}/'
|
||||
'${playlist.password}/$streamId';
|
||||
final ts = await _probeStream(c, Uri.parse('$base.ts'));
|
||||
// Laisser le panneau libérer la connexion (comptes à une connexion).
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
final hls = await _probeHls(c, Uri.parse('$base.m3u8'));
|
||||
return {'stream': streamId, 'ts': ts, 'm3u8': hls};
|
||||
} finally {
|
||||
if (client == null) c.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Suit les redirections à la main pour chronométrer chaque saut.
|
||||
Future<({http.StreamedResponse? response, List<Map<String, Object?>> hops, Uri url})>
|
||||
_open(http.Client c, Uri url, Stopwatch sw) async {
|
||||
final hops = <Map<String, Object?>>[];
|
||||
var current = url;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final request = http.Request('GET', current)
|
||||
..followRedirects = false
|
||||
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18';
|
||||
final response =
|
||||
await c.send(request).timeout(const Duration(seconds: 20));
|
||||
hops.add({
|
||||
'host': current.host,
|
||||
'status': response.statusCode,
|
||||
'headersMs': sw.elapsedMilliseconds,
|
||||
});
|
||||
final location = response.headers['location'];
|
||||
if (const {301, 302, 303, 307, 308}.contains(response.statusCode) &&
|
||||
location != null) {
|
||||
await response.stream.drain<void>().catchError((_) {});
|
||||
current = current.resolve(location);
|
||||
continue;
|
||||
}
|
||||
return (response: response, hops: hops, url: current);
|
||||
}
|
||||
return (response: null, hops: hops, url: current);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _probeStream(http.Client c, Uri url) async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final opened = await _open(c, url, sw);
|
||||
final response = opened.response;
|
||||
if (response == null || response.statusCode != 200) {
|
||||
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
|
||||
}
|
||||
var bytes = 0;
|
||||
int? firstByteMs;
|
||||
final sub = response.stream.listen(null);
|
||||
final done = Completer<void>();
|
||||
sub.onData((chunk) {
|
||||
firstByteMs ??= sw.elapsedMilliseconds;
|
||||
bytes += chunk.length;
|
||||
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
|
||||
if (bytes > 1000000 && !done.isCompleted) done.complete();
|
||||
});
|
||||
sub.onDone(() {
|
||||
if (!done.isCompleted) done.complete();
|
||||
});
|
||||
await done.future.timeout(const Duration(seconds: 15), onTimeout: () {});
|
||||
await sub.cancel();
|
||||
return {
|
||||
'hops': opened.hops,
|
||||
'firstByteMs': firstByteMs,
|
||||
'firstMegabyteMs': bytes > 1000000 ? sw.elapsedMilliseconds : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _probeHls(http.Client c, Uri url) async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final opened = await _open(c, url, sw);
|
||||
final response = opened.response;
|
||||
if (response == null || response.statusCode != 200) {
|
||||
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
|
||||
}
|
||||
final body = await response.stream.bytesToString();
|
||||
final playlistMs = sw.elapsedMilliseconds;
|
||||
final lines = const LineSplitter().convert(body);
|
||||
final segments =
|
||||
lines.where((l) => l.isNotEmpty && !l.startsWith('#')).toList();
|
||||
final target = lines
|
||||
.firstWhere((l) => l.startsWith('#EXT-X-TARGETDURATION'),
|
||||
orElse: () => '')
|
||||
.split(':')
|
||||
.last;
|
||||
if (segments.isEmpty) {
|
||||
return {'hops': opened.hops, 'playlistMs': playlistMs, 'segments': 0};
|
||||
}
|
||||
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
|
||||
final pick = segments.length >= 2
|
||||
? segments[segments.length - 2]
|
||||
: segments.last;
|
||||
final segment = await _probeStream(c, opened.url.resolve(pick));
|
||||
return {
|
||||
'hops': opened.hops,
|
||||
'playlistMs': playlistMs,
|
||||
'segments': segments.length,
|
||||
'targetDuration': target,
|
||||
'segment': segment,
|
||||
'segmentStartedAtMs': playlistMs,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "105.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.1.0"
|
||||
args:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bcrypt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bcrypt
|
||||
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive
|
||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_methods:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_methods
|
||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
shelf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_router
|
||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
shelf_static:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.14"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.31.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.19"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
xml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
+1
-1
@@ -4,7 +4,7 @@ publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
sdk: ^3.13.0
|
||||
|
||||
dependencies:
|
||||
shelf: ^1.4.1
|
||||
|
||||
+80
-3
@@ -18,13 +18,17 @@ import 'api/streaming_handler.dart';
|
||||
import 'api/proxy_handler.dart';
|
||||
import 'api/recordings_api.dart';
|
||||
import 'api/epg_api.dart';
|
||||
import 'api/logo_api.dart';
|
||||
import 'services/xmltv_epg_service.dart';
|
||||
import 'services/logo_catalog.dart';
|
||||
import 'api/season_passes_api.dart';
|
||||
import 'api/xtream_api_handler.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
import 'middleware/security_middleware.dart';
|
||||
import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
import 'utils/asset_versioning.dart';
|
||||
import 'api/upstream_probe.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
// Parse command line arguments
|
||||
@@ -121,11 +125,16 @@ void main(List<String> args) async {
|
||||
final usersHandler = UsersHandler(db);
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
// Logos de chaînes : URL du panneau d'abord, puis repli par nom sur le
|
||||
// dépôt public tv-logos quand l'hébergeur de picons du revendeur tombe.
|
||||
final logoApi = LogoApi(proxyHandler.handler, LogoCatalog());
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||
// l'EPG se limite alors au panneau de l'abonné.
|
||||
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,'
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,'
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz')
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.where((u) => u.isNotEmpty)
|
||||
@@ -204,6 +213,14 @@ void main(List<String> args) async {
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(recordingsRouter.call),
|
||||
)
|
||||
// Logos de chaînes avec repli (auth required ; le cookie de session
|
||||
// accompagne les requêtes d'image du navigateur)
|
||||
..get(
|
||||
'/api/logo',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
@@ -255,10 +272,47 @@ void main(List<String> args) async {
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/admin/upstream-probe?stream=<id> : chronomètre l'ouverture
|
||||
// d'une chaîne chez le fournisseur en .ts et en .m3u8 (diagnostic du
|
||||
// délai de zap ; aucun identifiant dans la réponse).
|
||||
router.get('/upstream-probe', (Request req) async {
|
||||
final user = req.context['user'] as User?;
|
||||
if (user == null || !user.isAdmin) {
|
||||
return Response.forbidden(
|
||||
jsonEncode({'error': 'Admin access required'}),
|
||||
);
|
||||
}
|
||||
final streamId = req.url.queryParameters['stream'] ?? '';
|
||||
if (!RegExp(r'^[0-9]+$').hasMatch(streamId)) {
|
||||
return Response.badRequest(body: 'stream=<id numérique> requis');
|
||||
}
|
||||
final playlist = await getPlaylist(req);
|
||||
if (playlist == null) return Response.forbidden('No playlist');
|
||||
final result = await probeUpstream(playlist, streamId);
|
||||
return Response.ok(
|
||||
jsonEncode(result),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
return router(request);
|
||||
}),
|
||||
);
|
||||
|
||||
// Les libs de lecture sont servies depuis l'image, plus depuis un CDN : si
|
||||
// elles manquent (build web incomplet, copie Docker partielle), le lecteur
|
||||
// échoue côté navigateur avec un simple « Échec du chargement » sans que
|
||||
// rien ne l'ait signalé au démarrage. On le dit ici, une fois, clairement.
|
||||
for (final lib in const ['hls.min.js', 'mpegts.min.js']) {
|
||||
final file = File('$webPath/vendor/$lib');
|
||||
if (!file.existsSync()) {
|
||||
print(
|
||||
'[Server] ATTENTION: web/vendor/$lib absent de $webPath — '
|
||||
'la lecture échouera dans le navigateur.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create static handler
|
||||
final baseStaticHandler = createStaticHandler(
|
||||
webPath,
|
||||
@@ -266,18 +320,41 @@ void main(List<String> args) async {
|
||||
listDirectories: false,
|
||||
);
|
||||
|
||||
// Points d'entrée réécrits avec `?v=<empreinte>` sur leurs JS : voir
|
||||
// asset_versioning.dart (un proxy qui cache les .js servait l'ancienne
|
||||
// app et l'ancien lecteur après chaque déploiement).
|
||||
final versionedEntryPoints = buildVersionedEntryPoints(webPath);
|
||||
print('[Server] Assets versionnés : ${versionedEntryPoints.keys.join(', ')}');
|
||||
|
||||
// Wrap static handler to enforce cache policies
|
||||
FutureOr<Response> staticHandler(Request request) async {
|
||||
final path = request.url.path;
|
||||
|
||||
final versioned =
|
||||
versionedEntryPoints[path.isEmpty ? 'index.html' : path];
|
||||
if (versioned != null && request.method == 'GET') {
|
||||
return Response.ok(
|
||||
versioned,
|
||||
headers: {
|
||||
'Content-Type': path.endsWith('.js')
|
||||
? 'text/javascript; charset=utf-8'
|
||||
: 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final response = await baseStaticHandler(request);
|
||||
|
||||
// Disable cache for entry points to ensure updates are seen immediately.
|
||||
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
|
||||
// versions: keep them cacheable or every player open re-downloads them.
|
||||
final path = request.url.path;
|
||||
final isVendored = path.startsWith('vendor/');
|
||||
if (!isVendored &&
|
||||
(path.isEmpty ||
|
||||
path == 'index.html' ||
|
||||
path.endsWith('.html') ||
|
||||
path.endsWith('.js') ||
|
||||
path.endsWith('.json'))) {
|
||||
return response.change(
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// One running FFmpeg transcoding session (live, VOD or recording playback).
|
||||
class FfmpegSession {
|
||||
final String id;
|
||||
@@ -72,6 +74,17 @@ class FfmpegSessionManager {
|
||||
|
||||
bool contains(String id) => _sessions.containsKey(id);
|
||||
|
||||
/// Vrai si [getOrStart] lancerait un nouveau processus FFmpeg pour [id] —
|
||||
/// donc une nouvelle connexion au fournisseur.
|
||||
bool needsStart(String id, {required bool isLive}) {
|
||||
final existing = _sessions[id];
|
||||
if (existing == null) return true;
|
||||
if (!existing.exited) return false;
|
||||
// A VOD/recording transcode that finished cleanly is still fully
|
||||
// playable from its segments — reuse it instead of re-transcoding.
|
||||
return isLive || existing.exitCode != 0 || !_playlistComplete(existing);
|
||||
}
|
||||
|
||||
/// Returns the existing healthy session or starts a new FFmpeg process.
|
||||
///
|
||||
/// [argsBuilder] receives the session working directory and returns the
|
||||
@@ -84,17 +97,11 @@ class FfmpegSessionManager {
|
||||
required List<String> Function(Directory dir) argsBuilder,
|
||||
}) async {
|
||||
final existing = _sessions[id];
|
||||
if (existing != null && !existing.exited) {
|
||||
if (existing != null && !needsStart(id, isLive: isLive)) {
|
||||
existing.touch();
|
||||
return existing;
|
||||
}
|
||||
if (existing != null) {
|
||||
// A VOD/recording transcode that finished cleanly is still fully
|
||||
// playable from its segments — reuse it instead of re-transcoding.
|
||||
if (!isLive && existing.exitCode == 0 && _playlistComplete(existing)) {
|
||||
existing.touch();
|
||||
return existing;
|
||||
}
|
||||
// Process died: clean up before restarting
|
||||
killSession(id);
|
||||
}
|
||||
@@ -121,7 +128,9 @@ class FfmpegSessionManager {
|
||||
process.stderr.transform(utf8.decoder).listen((data) {
|
||||
session.recentStderr.add(data);
|
||||
if (session.recentStderr.length > 20) session.recentStderr.removeAt(0);
|
||||
print('[FFmpeg $id] $data');
|
||||
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from 'http://…' ») :
|
||||
// masquer les identifiants Xtream avant de journaliser.
|
||||
print('[FFmpeg $id] ${LogRedactor.redactUrl(data)}');
|
||||
});
|
||||
|
||||
process.exitCode.then((code) {
|
||||
@@ -143,29 +152,54 @@ class FfmpegSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the session's playlist references at least one segment.
|
||||
/// Fails fast when the process dies before producing output, returning
|
||||
/// the recent stderr for diagnostics.
|
||||
/// Waits until the session's playlist references at least [minSegments]
|
||||
/// segments. Fails fast when the process dies before producing output,
|
||||
/// returning the recent stderr for diagnostics.
|
||||
///
|
||||
/// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la
|
||||
/// playlist dès le premier segment le fait partir avec 4 s de marge sur un
|
||||
/// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La
|
||||
/// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel
|
||||
/// que soit son nombre de segments : rien de plus n'arrivera.
|
||||
Future<({bool ready, String? error})> waitForPlaylist(
|
||||
FfmpegSession session, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
if (playlistFile.existsSync()) {
|
||||
final content = playlistFile.readAsStringSync();
|
||||
final segments = '.ts'.allMatches(content).length;
|
||||
if (segments >= minSegments ||
|
||||
(segments > 0 && content.contains('#EXT-X-ENDLIST'))) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
}
|
||||
if (session.exited) {
|
||||
// Un encodeur qui s'est terminé proprement en ayant produit moins de
|
||||
// segments que demandé a simplement fini : c'est un succès.
|
||||
if (session.exitCode == 0 &&
|
||||
playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (
|
||||
ready: false,
|
||||
error: 'FFmpeg exited (${session.exitCode}): '
|
||||
'${session.recentStderr.join().trim()}'
|
||||
);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde de
|
||||
// latence pure entre la disponibilité de la playlist et sa réponse.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
// Le délai est écoulé : mieux vaut servir ce qui existe que rien.
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (ready: false, error: 'Timeout waiting for transcoder');
|
||||
}
|
||||
@@ -183,6 +217,28 @@ class FfmpegSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tue les sessions frères d'un même contenu (`rec_<id>_t0`, `rec_<id>_t600`…)
|
||||
/// que plus personne ne consomme depuis [idleFor].
|
||||
///
|
||||
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
|
||||
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
|
||||
/// barre de progression laisserait autant de processus vivants jusqu'au
|
||||
/// TTL de 15 minutes. Le délai d'inactivité protège un second spectateur
|
||||
/// du même enregistrement : sa session est « touchée » à chaque segment.
|
||||
void killIdleSiblings(
|
||||
String prefix, {
|
||||
required String keep,
|
||||
Duration idleFor = const Duration(seconds: 30),
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (final session in _sessions.values.toList()) {
|
||||
if (session.id == keep || !session.id.startsWith(prefix)) continue;
|
||||
if (now.difference(session.lastAccess) < idleFor) continue;
|
||||
print('[FFmpegManager] Dropping stale sibling ${session.id}');
|
||||
killSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
void killAll() {
|
||||
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
|
||||
for (final id in _sessions.keys.toList()) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Repli GPU → CPU des transcodages.
|
||||
///
|
||||
/// Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
|
||||
/// sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
|
||||
/// FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
|
||||
/// repli, le lecteur recevait un 502, relançait… et retombait sur le même
|
||||
/// GPU saturé : le flux restait illisible tant que la carte était pleine.
|
||||
|
||||
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
|
||||
///
|
||||
/// N'est consulté que pour une session lancée sur le GPU : « opening
|
||||
/// encoder » y désigne donc forcément h264_nvenc.
|
||||
final _gpuFailurePattern = RegExp(
|
||||
r'nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|'
|
||||
r'out of memory|Device creation failed|hwaccel|opening encoder',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
|
||||
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
|
||||
/// réglerait pas, et qui doublerait inutilement la charge d'un serveur déjà
|
||||
/// occupé.
|
||||
bool isGpuFailure(String? error) =>
|
||||
error != null && _gpuFailurePattern.hasMatch(error);
|
||||
|
||||
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
|
||||
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
|
||||
/// leur repli.
|
||||
class GpuHealth {
|
||||
final Duration cooldown;
|
||||
final DateTime Function() _now;
|
||||
DateTime? _unavailableUntil;
|
||||
|
||||
GpuHealth({
|
||||
this.cooldown = const Duration(minutes: 2),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
bool get available =>
|
||||
_unavailableUntil == null || !_now().isBefore(_unavailableUntil!);
|
||||
|
||||
void markFailed() => _unavailableUntil = _now().add(cooldown);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
|
||||
///
|
||||
/// POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
|
||||
/// revendeur. Quand cette machine tombe — cas observé : serveur de picons en
|
||||
/// maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
|
||||
/// Le dépôt `tv-logos` est maintenu, nommé de façon régulière
|
||||
/// (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
|
||||
class LogoCatalog {
|
||||
LogoCatalog({
|
||||
http.Client? client,
|
||||
this.listingTtl = const Duration(hours: 24),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
|
||||
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
|
||||
final Duration listingTtl;
|
||||
|
||||
/// Recul après un échec de téléchargement de liste (limite de débit,
|
||||
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
|
||||
final Duration retryBackoff;
|
||||
|
||||
final Map<String, _Listing> _listings = {};
|
||||
final Map<String, Future<_Listing?>> _listingsInFlight = {};
|
||||
final Map<String, DateTime> _listingFailedAt = {};
|
||||
final Map<String, List<int>> _bytes = {};
|
||||
|
||||
/// Octets PNG du logo correspondant à [channelName], ou `null`.
|
||||
Future<List<int>?> logoFor(String channelName) async {
|
||||
final country = countryFolderFor(channelName);
|
||||
final listing = await _listingFor(country);
|
||||
if (listing == null) return null;
|
||||
|
||||
final key = matchLogoKey(channelName, listing.urls.keys);
|
||||
if (key == null) return null;
|
||||
|
||||
final url = listing.urls[key]!;
|
||||
final cached = _bytes[url];
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final response = await _client
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) return null;
|
||||
return _bytes[url] = response.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Listing?> _listingFor(String country) async {
|
||||
final cached = _listings[country];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.fetchedAt) < listingTtl) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final failedAt = _listingFailedAt[country];
|
||||
if (failedAt != null &&
|
||||
DateTime.now().difference(failedAt) < retryBackoff) {
|
||||
// Liste périmée plutôt que rien : les logos ne changent pas d'un jour
|
||||
// à l'autre.
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Les tuiles d'une grille arrivent en rafale : une seule requête de liste.
|
||||
return _listingsInFlight[country] ??=
|
||||
_fetchListing(country).whenComplete(() {
|
||||
_listingsInFlight.remove(country);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_Listing?> _fetchListing(String country) async {
|
||||
final url = 'https://api.github.com/repos/tv-logo/tv-logos/contents/'
|
||||
'countries/$country';
|
||||
try {
|
||||
final response = await _client.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'},
|
||||
).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
final urls = <String, String>{};
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is List) {
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map) continue;
|
||||
final name = entry['name']?.toString() ?? '';
|
||||
final download = entry['download_url']?.toString();
|
||||
if (download == null || !name.endsWith('.png')) continue;
|
||||
urls[_keyFromFileName(name)] = download;
|
||||
}
|
||||
}
|
||||
|
||||
final listing = _Listing(urls, DateTime.now());
|
||||
_listings[country] = listing;
|
||||
_listingFailedAt.remove(country);
|
||||
print('[LogoCatalog] $country : ${urls.length} logos');
|
||||
return listing;
|
||||
} catch (e) {
|
||||
_listingFailedAt[country] = DateTime.now();
|
||||
print('[LogoCatalog] liste $country indisponible : $e');
|
||||
return _listings[country];
|
||||
}
|
||||
}
|
||||
|
||||
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
|
||||
static String _keyFromFileName(String name) {
|
||||
final base = name.substring(0, name.length - '.png'.length);
|
||||
return base.replaceFirst(RegExp(r'-[a-z]{2}$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
class _Listing {
|
||||
_Listing(this.urls, this.fetchedAt);
|
||||
|
||||
/// Clé normalisée → URL de téléchargement brute.
|
||||
final Map<String, String> urls;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
|
||||
const _countryFolders = {
|
||||
'fr': 'france',
|
||||
'be': 'belgium',
|
||||
'ch': 'switzerland',
|
||||
'uk': 'united-kingdom',
|
||||
'gb': 'united-kingdom',
|
||||
'us': 'united-states',
|
||||
'ca': 'canada',
|
||||
'de': 'germany',
|
||||
'es': 'spain',
|
||||
'it': 'italy',
|
||||
'pt': 'portugal',
|
||||
'nl': 'netherlands',
|
||||
};
|
||||
|
||||
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
|
||||
final _countryPrefix = RegExp(r'^\W*([A-Za-z]{2})[\s\-:|]+');
|
||||
|
||||
/// Dossier `tv-logos` à consulter pour [channelName]. France par défaut :
|
||||
/// c'est l'audience de l'application.
|
||||
String countryFolderFor(String channelName) {
|
||||
final match = _countryPrefix.firstMatch(channelName);
|
||||
final code = match?.group(1)?.toLowerCase();
|
||||
return _countryFolders[code] ?? 'france';
|
||||
}
|
||||
|
||||
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
|
||||
/// de flux. Ils sont propres au panneau et absents des noms de logos.
|
||||
const _noise = {
|
||||
'fhd', 'uhd', 'hd', 'sd', '4k', '8k', 'hdr', 'hevc', 'h264', 'h265',
|
||||
'1080p', '720p', '50fps', 'fps', 'raw', 'backup', 'vip', 'multi',
|
||||
'vf', 'vo', 'vostfr',
|
||||
};
|
||||
|
||||
const _accents = {
|
||||
'à': 'a', 'á': 'a', 'â': 'a', 'ä': 'a', 'è': 'e', 'é': 'e', 'ê': 'e',
|
||||
'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ò': 'o', 'ó': 'o',
|
||||
'ô': 'o', 'ö': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c',
|
||||
};
|
||||
|
||||
List<String> _tokens(String channelName) {
|
||||
var name = channelName;
|
||||
final prefix = _countryPrefix.firstMatch(name);
|
||||
if (prefix != null &&
|
||||
_countryFolders.containsKey(prefix.group(1)!.toLowerCase())) {
|
||||
name = name.substring(prefix.end);
|
||||
}
|
||||
|
||||
final folded = StringBuffer();
|
||||
for (final rune in name.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
|
||||
return folded
|
||||
.toString()
|
||||
.replaceAll('+', ' plus ')
|
||||
.replaceAll('&', ' and ')
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty && !_noise.contains(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Clé de logo la plus proche de [channelName] parmi [available], ou `null`.
|
||||
///
|
||||
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
|
||||
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
|
||||
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
|
||||
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
|
||||
/// France 24.
|
||||
String? matchLogoKey(String channelName, Iterable<String> available) {
|
||||
final keys = available.toSet();
|
||||
final tokens = _tokens(channelName);
|
||||
if (tokens.isEmpty) return null;
|
||||
|
||||
List<String> withoutLive(List<String> parts) =>
|
||||
parts.where((t) => t != 'live').toList();
|
||||
|
||||
String? exact(List<String> parts) {
|
||||
final slug = parts.join('-');
|
||||
return parts.isNotEmpty && keys.contains(slug) ? slug : null;
|
||||
}
|
||||
|
||||
String? longerVariant(List<String> parts) {
|
||||
if (parts.length < 2) return null;
|
||||
final slug = parts.join('-');
|
||||
final longer = keys.where((k) => k.startsWith('$slug-')).toList()
|
||||
..sort((a, b) {
|
||||
final byLength = a.length.compareTo(b.length);
|
||||
return byLength != 0 ? byLength : a.compareTo(b);
|
||||
});
|
||||
return longer.isEmpty ? null : longer.first;
|
||||
}
|
||||
|
||||
final noLive = withoutLive(tokens);
|
||||
final direct = exact(tokens) ??
|
||||
exact(noLive) ??
|
||||
longerVariant(tokens) ??
|
||||
longerVariant(noLive);
|
||||
if (direct != null) return direct;
|
||||
|
||||
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
|
||||
if (RegExp(r'^\d+$').hasMatch(tokens.last)) {
|
||||
final brand = withoutLive(tokens.sublist(0, tokens.length - 1));
|
||||
if (brand.length >= 2) return exact(brand) ?? longerVariant(brand);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -533,7 +533,12 @@ class RecordingScheduler {
|
||||
await _launchFfmpeg(active);
|
||||
} catch (e, st) {
|
||||
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
||||
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
||||
// `ProcessException` liste les arguments FFmpeg, URL de la source
|
||||
// comprise : masquer les identifiants avant de journaliser.
|
||||
print(
|
||||
'[RecordingScheduler] ERREUR dans _startRecording: '
|
||||
'${LogRedactor.redactUrl('$e')}\n$st',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
recording.id,
|
||||
'failed',
|
||||
@@ -662,7 +667,10 @@ class RecordingScheduler {
|
||||
try {
|
||||
await _launchFfmpeg(active);
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Relance impossible: $e');
|
||||
print(
|
||||
'[RecordingScheduler] Relance impossible: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
_active.remove(recording.id);
|
||||
await _finalize(
|
||||
active,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// Connexions ouvertes vers le fournisseur Xtream, par compte.
|
||||
///
|
||||
/// POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
|
||||
/// simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
|
||||
/// lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
|
||||
/// ou la fin du téléchargement pour un film, le temps de détecter la
|
||||
/// déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
|
||||
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
||||
/// films « indisponibles » et les zaps qui échouent.
|
||||
///
|
||||
/// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
|
||||
/// connexions ORPHELINES du même compte (plus personne ne les consomme),
|
||||
/// les plus anciennes d'abord, jusqu'à lui faire de la place.
|
||||
///
|
||||
/// Un flux encore regardé n'est jamais coupé. Une première version coupait
|
||||
/// sans distinction (« le dernier gagne ») : deux lecteurs ouverts sur un
|
||||
/// compte à une connexion se coupaient alors en boucle, chacun relançant
|
||||
/// aussitôt — plus aucune image nulle part (constaté en prod). Le second
|
||||
/// lecteur est désormais refusé par le panneau, comme avant, et le premier
|
||||
/// continue.
|
||||
///
|
||||
/// Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
|
||||
class UpstreamSlots {
|
||||
final DateTime Function() _now;
|
||||
final Map<String, _Slot> _slots = {};
|
||||
int _nextToken = 0;
|
||||
|
||||
UpstreamSlots({DateTime Function()? now}) : _now = now ?? DateTime.now;
|
||||
|
||||
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
|
||||
///
|
||||
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
||||
/// le même identifiant ne doit pas être désinscrite par la fin de
|
||||
/// l'ancien processus.
|
||||
///
|
||||
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
|
||||
/// connexion active n'est jamais coupée par [makeRoom].
|
||||
int register({
|
||||
required String id,
|
||||
required String account,
|
||||
required void Function() release,
|
||||
bool Function()? isActive,
|
||||
}) {
|
||||
final token = ++_nextToken;
|
||||
_slots[id] = _Slot(token, account, _now(), release, isActive);
|
||||
return token;
|
||||
}
|
||||
|
||||
void unregister(String id, {int? token}) {
|
||||
final slot = _slots[id];
|
||||
if (slot == null) return;
|
||||
if (token != null && slot.token != token) return;
|
||||
_slots.remove(id);
|
||||
}
|
||||
|
||||
int countFor(String account) =>
|
||||
_slots.values.where((s) => s.account == account).length;
|
||||
|
||||
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
|
||||
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
|
||||
///
|
||||
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
|
||||
/// d'interrompre un autre spectateur.
|
||||
List<String> makeRoom(String account, {required int max, required String keep}) {
|
||||
if (max <= 0) return const [];
|
||||
final others = _slots.entries
|
||||
.where((e) => e.value.account == account && e.key != keep)
|
||||
.toList();
|
||||
|
||||
final excess = others.length - (max - 1);
|
||||
if (excess <= 0) return const [];
|
||||
|
||||
final orphans = others.where((e) => !e.value.active).toList()
|
||||
..sort((a, b) => a.value.since.compareTo(b.value.since));
|
||||
|
||||
final freed = <String>[];
|
||||
for (final entry in orphans.take(excess)) {
|
||||
_slots.remove(entry.key);
|
||||
try {
|
||||
entry.value.release();
|
||||
} catch (_) {}
|
||||
freed.add(entry.key);
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
}
|
||||
|
||||
class _Slot {
|
||||
final int token;
|
||||
final String account;
|
||||
final DateTime since;
|
||||
final void Function() release;
|
||||
final bool Function()? isActive;
|
||||
_Slot(this.token, this.account, this.since, this.release, this.isActive);
|
||||
|
||||
bool get active {
|
||||
try {
|
||||
return isActive?.call() ?? false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
|
||||
/// même quota chez le fournisseur.
|
||||
String accountKeyOf(PlaylistConfig p) => '${p.dns}|${p.username}';
|
||||
|
||||
/// `user_info.max_connections` d'une réponse `player_api.php`, ou null.
|
||||
int? parseMaxConnections(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final info = json['user_info'];
|
||||
if (info is! Map) return null;
|
||||
final raw = info['max_connections'];
|
||||
final value = raw is int ? raw : int.tryParse('$raw');
|
||||
return (value != null && value > 0) ? value : null;
|
||||
}
|
||||
|
||||
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
|
||||
///
|
||||
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
|
||||
/// répondre (mesuré en prod), délai qui s'ajoutait au démarrage du premier
|
||||
/// flux puis de chaque flux suivant l'expiration du cache. La valeur connue
|
||||
/// (même périmée), sinon [fallback], est rendue immédiatement ; la lecture
|
||||
/// chez le panneau se fait en arrière-plan. Sans risque : le quota ne sert
|
||||
/// qu'à décider quels flux ORPHELINS couper.
|
||||
class AccountLimits {
|
||||
final Duration ttl;
|
||||
final Future<int?> Function(PlaylistConfig) _fetch;
|
||||
final Map<String, ({int max, DateTime at})> _cache = {};
|
||||
final Set<String> _refreshing = {};
|
||||
|
||||
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
|
||||
/// cas de loin le plus courant chez les fournisseurs Xtream.
|
||||
static const fallback = 1;
|
||||
|
||||
AccountLimits({
|
||||
this.ttl = const Duration(minutes: 10),
|
||||
Future<int?> Function(PlaylistConfig)? fetch,
|
||||
}) : _fetch = fetch ?? _fetchFromPanel;
|
||||
|
||||
Future<int> maxFor(PlaylistConfig p) async {
|
||||
final key = accountKeyOf(p);
|
||||
final cached = _cache[key];
|
||||
if (cached == null || DateTime.now().difference(cached.at) >= ttl) {
|
||||
_refresh(key, p);
|
||||
}
|
||||
return cached?.max ?? fallback;
|
||||
}
|
||||
|
||||
void _refresh(String key, PlaylistConfig p) {
|
||||
if (!_refreshing.add(key)) return; // une seule lecture à la fois
|
||||
Future<int?>.sync(() => _fetch(p))
|
||||
.then<int?>((v) => v, onError: (Object _) => null)
|
||||
.then((value) {
|
||||
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
|
||||
if (value != null) _cache[key] = (max: value, at: DateTime.now());
|
||||
}).whenComplete(() => _refreshing.remove(key));
|
||||
}
|
||||
|
||||
static Future<int?> _fetchFromPanel(PlaylistConfig p) async {
|
||||
final uri = Uri.parse('${p.dns}/player_api.php').replace(
|
||||
queryParameters: {'username': p.username, 'password': p.password},
|
||||
);
|
||||
final client = http.Client();
|
||||
try {
|
||||
final response = await client.get(uri, headers: {
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
}).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) return null;
|
||||
return parseMaxConnections(jsonDecode(response.body));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml_events.dart';
|
||||
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Guide TV construit à partir de dumps XMLTV publics.
|
||||
///
|
||||
/// Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou
|
||||
@@ -19,6 +23,9 @@ class XmltvEpgService {
|
||||
required this.sourceUrls,
|
||||
this.refreshInterval = const Duration(hours: 6),
|
||||
this.retention = const Duration(hours: 6),
|
||||
this.horizon = const Duration(hours: 48),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
this.downloadTimeout = const Duration(minutes: 5),
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
@@ -33,11 +40,32 @@ class XmltvEpgService {
|
||||
/// garder double la taille de l'index.
|
||||
final Duration retention;
|
||||
|
||||
/// Les programmes qui commencent au-delà de cet horizon sont écartés à
|
||||
/// l'indexation.
|
||||
///
|
||||
/// Un dump national couvre souvent sept jours pour un millier de chaînes ;
|
||||
/// tout garder en mémoire dans le conteneur coûte des centaines de
|
||||
/// mégaoctets pour un guide qui n'affiche que le programme courant et les
|
||||
/// suivants.
|
||||
final Duration horizon;
|
||||
|
||||
/// Délai minimal entre deux tentatives quand aucune source n'a répondu.
|
||||
///
|
||||
/// Sans ce recul, une source morte était retéléchargée à chaque consultation
|
||||
/// du guide — une requête sortante par chaîne affichée, soit exactement le
|
||||
/// flot qu'un index est censé éviter.
|
||||
final Duration retryBackoff;
|
||||
|
||||
/// Plafond de téléchargement d'un dump. Le guide est facultatif : mieux vaut
|
||||
/// abandonner et servir le panneau que faire attendre l'utilisateur.
|
||||
final Duration downloadTimeout;
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// clé de chaîne normalisée → programmes triés par heure de début.
|
||||
Map<String, List<XmltvProgramme>> _index = {};
|
||||
DateTime? _indexedAt;
|
||||
DateTime? _failedAt;
|
||||
Future<void>? _refreshInFlight;
|
||||
|
||||
bool get hasData => _index.isNotEmpty;
|
||||
@@ -54,8 +82,39 @@ class XmltvEpgService {
|
||||
String? displayName,
|
||||
}) async {
|
||||
await ensureFresh();
|
||||
for (final candidate in [channelId, displayName]) {
|
||||
final key = normalizeKey(candidate);
|
||||
final direct = _lookup(channelId, displayName);
|
||||
if (direct.isNotEmpty) return direct;
|
||||
|
||||
// Chaîne décalée sans guide propre (« TF1 +1 » absent des dumps) : le
|
||||
// guide de la chaîne principale, une heure plus tard, est exact.
|
||||
if (displayName != null && _plusOne.hasMatch(displayName)) {
|
||||
final base = displayName.replaceAll(_plusOne, ' ');
|
||||
final hits = _lookup(null, base);
|
||||
return [
|
||||
for (final p in hits)
|
||||
XmltvProgramme(
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
start: p.start.add(const Duration(hours: 1)),
|
||||
stop: p.stop.add(const Duration(hours: 1)),
|
||||
),
|
||||
];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static final _plusOne = RegExp(r'\+\s*1(?![0-9])');
|
||||
|
||||
List<XmltvProgramme> _lookup(String? channelId, String? displayName) {
|
||||
final keys = [
|
||||
normalizeKey(channelId),
|
||||
if (displayName != null) ...nameKeys(displayName),
|
||||
// Clés souples en dernier recours, dans leur propre espace de noms.
|
||||
_looseIndexKey(looseKey(channelId)),
|
||||
if (displayName != null)
|
||||
_looseIndexKey(looseKey(cleanChannelName(displayName).name)),
|
||||
];
|
||||
for (final key in keys) {
|
||||
if (key.isEmpty) continue;
|
||||
final hit = _index[key];
|
||||
if (hit != null && hit.isNotEmpty) return hit;
|
||||
@@ -63,6 +122,110 @@ class XmltvEpgService {
|
||||
return const [];
|
||||
}
|
||||
|
||||
static String _looseIndexKey(String loose) => loose.isEmpty ? '' : '~$loose';
|
||||
|
||||
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
|
||||
static const _looseNoise = {
|
||||
'channel', 'tv', 'hd', 'fhd', 'uhd', 'sd', '4k', 'hevc',
|
||||
};
|
||||
|
||||
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
|
||||
static const _countryCodes = {
|
||||
'fr', 'be', 'ch', 'lu', 'mc', 'ca', 'uk', 'us', 'es', 'it', 'de', 'pt',
|
||||
'nl',
|
||||
};
|
||||
|
||||
/// Clé « souple » : sans code pays final ni mots génériques
|
||||
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
|
||||
///
|
||||
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
|
||||
/// rattrape les variantes d'écriture sans fausser les correspondances
|
||||
/// exactes.
|
||||
static String looseKey(String? raw) {
|
||||
if (raw == null) return '';
|
||||
final folded = StringBuffer();
|
||||
for (final rune in raw.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
final tokens = folded
|
||||
.toString()
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
if (tokens.length > 1 && _countryCodes.contains(tokens.last)) {
|
||||
tokens.removeLast();
|
||||
}
|
||||
tokens.removeWhere(_looseNoise.contains);
|
||||
return tokens.join();
|
||||
}
|
||||
|
||||
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
|
||||
static final _countryPrefix = RegExp(
|
||||
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
|
||||
);
|
||||
|
||||
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
|
||||
/// chaîne elle-même.
|
||||
static final _qualitySuffix = RegExp(
|
||||
r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|'
|
||||
r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
|
||||
///
|
||||
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
|
||||
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
|
||||
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
|
||||
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
|
||||
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
|
||||
///
|
||||
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
|
||||
/// de TF1.
|
||||
static List<String> nameKeys(String name) {
|
||||
final keys = <String>[normalizeKey(name)];
|
||||
final cleaned = cleanChannelName(name);
|
||||
final country = cleaned.country;
|
||||
final base = normalizeKey(cleaned.name);
|
||||
if (base.isNotEmpty) {
|
||||
keys.add(base);
|
||||
keys.add('$base${country ?? 'fr'}');
|
||||
}
|
||||
return keys.where((k) => k.isNotEmpty).toSet().toList();
|
||||
}
|
||||
|
||||
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
|
||||
/// où les dumps écrivent `France.3.-.Alpes.fr`.
|
||||
static final _franceAbbrev = RegExp(r'^F\s?([2-5])\b', caseSensitive: false);
|
||||
|
||||
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
|
||||
/// marqueurs de qualité ; [country] = code pays du préfixe, s'il y en a.
|
||||
static ({String name, String? country}) cleanChannelName(String raw) {
|
||||
var cleaned = raw;
|
||||
String? country;
|
||||
final prefix = _countryPrefix.firstMatch(cleaned);
|
||||
if (prefix != null) {
|
||||
country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase();
|
||||
cleaned = cleaned.substring(prefix.end);
|
||||
}
|
||||
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
|
||||
// chiffre, ni ponctuation utile.
|
||||
cleaned = cleaned
|
||||
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
|
||||
.trim();
|
||||
String previous;
|
||||
do {
|
||||
previous = cleaned;
|
||||
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
|
||||
} while (cleaned != previous && cleaned.isNotEmpty);
|
||||
cleaned = cleaned.replaceFirstMapped(
|
||||
_franceAbbrev,
|
||||
(m) => 'France ${m[1]}',
|
||||
);
|
||||
return (name: cleaned, country: country);
|
||||
}
|
||||
|
||||
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
|
||||
/// partagent le même téléchargement.
|
||||
Future<void> ensureFresh() {
|
||||
@@ -70,6 +233,14 @@ class XmltvEpgService {
|
||||
? null
|
||||
: DateTime.now().difference(_indexedAt!);
|
||||
if (age != null && age < refreshInterval) return Future.value();
|
||||
|
||||
final sinceFailure = _failedAt == null
|
||||
? null
|
||||
: DateTime.now().difference(_failedAt!);
|
||||
if (sinceFailure != null && sinceFailure < retryBackoff) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _refreshInFlight ??= _refresh().whenComplete(() {
|
||||
_refreshInFlight = null;
|
||||
});
|
||||
@@ -82,9 +253,13 @@ class XmltvEpgService {
|
||||
var ok = 0;
|
||||
|
||||
for (final url in sourceUrls) {
|
||||
// La source peut être le `xmltv.php` du panneau, dont l'URL porte les
|
||||
// identifiants de l'abonné en clair : ne jamais la journaliser telle
|
||||
// quelle (elle finirait dans `docker logs`). Le texte de l'exception
|
||||
// est masqué aussi, un `ClientException` recopiant l'URL demandée.
|
||||
final safeUrl = LogRedactor.redactUrl(url);
|
||||
try {
|
||||
final body = await _download(url);
|
||||
final parsed = _parse(body);
|
||||
final parsed = await _downloadAndParse(url);
|
||||
// Première source servie gagne : les suivantes ne comblent que les
|
||||
// chaînes encore absentes.
|
||||
for (final entry in parsed.entries) {
|
||||
@@ -92,52 +267,92 @@ class XmltvEpgService {
|
||||
}
|
||||
ok++;
|
||||
print(
|
||||
'[XmltvEpg] $url : ${parsed.length} chaînes indexées',
|
||||
'[XmltvEpg] $safeUrl : ${parsed.length} chaînes indexées',
|
||||
);
|
||||
} catch (e) {
|
||||
print('[XmltvEpg] $url : échec ($e)');
|
||||
print(
|
||||
'[XmltvEpg] $safeUrl : échec (${LogRedactor.redactUrl('$e')})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (ok == 0) {
|
||||
// Garder l'index précédent plutôt que de servir un guide vide.
|
||||
_failedAt = DateTime.now();
|
||||
print('[XmltvEpg] aucune source disponible, index précédent conservé');
|
||||
return;
|
||||
}
|
||||
|
||||
_index = merged;
|
||||
_indexedAt = DateTime.now();
|
||||
_failedAt = null;
|
||||
print('[XmltvEpg] index prêt : ${merged.length} chaînes');
|
||||
}
|
||||
|
||||
Future<String> _download(String url) async {
|
||||
final response = await _client
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(minutes: 5));
|
||||
/// Télécharge [url] puis l'indexe dans un isolate dédié.
|
||||
///
|
||||
/// Le serveur n'a qu'une boucle d'événements : décompresser et parser un
|
||||
/// dump de plusieurs milliers de chaînes dessus la gelait pendant des
|
||||
/// secondes. Plus rien d'autre ne répondait — en particulier le relais
|
||||
/// `turbo.ts`, qui cessait d'envoyer des octets au lecteur : la lecture
|
||||
/// se coupait systématiquement au démarrage, au moment précis où l'écran
|
||||
/// des chaînes demandait le guide.
|
||||
Future<Map<String, List<XmltvProgramme>>> _downloadAndParse(
|
||||
String url,
|
||||
) async {
|
||||
final response =
|
||||
await _client.get(Uri.parse(url)).timeout(downloadTimeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
List<int> bytes = response.bodyBytes;
|
||||
// Variables locales : la fermeture envoyée à l'isolate ne doit pas
|
||||
// capturer `this` (le client HTTP n'est pas transférable).
|
||||
final bytes = response.bodyBytes;
|
||||
final retention = this.retention;
|
||||
final horizon = this.horizon;
|
||||
return Isolate.run(
|
||||
() => _decodeAndParse(bytes, retention: retention, horizon: horizon),
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, List<XmltvProgramme>> _decodeAndParse(
|
||||
Uint8List raw, {
|
||||
required Duration retention,
|
||||
required Duration horizon,
|
||||
}) {
|
||||
List<int> bytes = raw;
|
||||
// Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on
|
||||
// regarde le nombre magique plutôt que de se fier aux en-têtes.
|
||||
if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) {
|
||||
bytes = gzip.decode(bytes);
|
||||
}
|
||||
return utf8.decode(bytes, allowMalformed: true);
|
||||
return _parse(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
retention: retention,
|
||||
horizon: horizon,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exposé pour les tests.
|
||||
Map<String, List<XmltvProgramme>> parseForTest(String xml) => _parse(xml);
|
||||
Map<String, List<XmltvProgramme>> parseForTest(String xml) =>
|
||||
_parse(xml, retention: retention, horizon: horizon);
|
||||
|
||||
Map<String, List<XmltvProgramme>> _parse(String xml) {
|
||||
static Map<String, List<XmltvProgramme>> _parse(
|
||||
String xml, {
|
||||
required Duration retention,
|
||||
required Duration horizon,
|
||||
}) {
|
||||
final cutoff = DateTime.now().toUtc().subtract(retention);
|
||||
final limit = DateTime.now().toUtc().add(horizon);
|
||||
final byChannel = <String, List<XmltvProgramme>>{};
|
||||
|
||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
|
||||
// <display-name> différent de l'identifiant. On indexe les deux pour
|
||||
// maximiser les correspondances.
|
||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
|
||||
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
|
||||
// les indexe TOUS : seul le premier l'était, et c'était souvent la
|
||||
// variante la moins proche du nom annoncé par le panneau.
|
||||
final aliases = <String, String>{};
|
||||
final displayNames = <String>[];
|
||||
|
||||
String? channelId;
|
||||
String? programmeChannel;
|
||||
@@ -154,6 +369,7 @@ class XmltvEpgService {
|
||||
case 'channel':
|
||||
channelId = _attr(event, 'id');
|
||||
displayName.clear();
|
||||
displayNames.clear();
|
||||
case 'programme':
|
||||
programmeChannel = _attr(event, 'channel');
|
||||
start = parseXmltvDate(_attr(event, 'start'));
|
||||
@@ -176,22 +392,35 @@ class XmltvEpgService {
|
||||
case 'desc':
|
||||
desc.write(text);
|
||||
case 'display-name':
|
||||
if (displayName.isEmpty) displayName.write(text);
|
||||
displayName.write(text);
|
||||
}
|
||||
} else if (event is XmlEndElementEvent) {
|
||||
switch (event.name) {
|
||||
case 'display-name':
|
||||
displayNames.add(displayName.toString());
|
||||
displayName.clear();
|
||||
case 'channel':
|
||||
final id = normalizeKey(channelId);
|
||||
final name = normalizeKey(displayName.toString());
|
||||
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
||||
aliases[name] = id;
|
||||
for (final raw in displayNames) {
|
||||
final name = normalizeKey(raw);
|
||||
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
||||
aliases.putIfAbsent(name, () => id);
|
||||
}
|
||||
}
|
||||
// Clés souples (espace de noms « ~ ») : premier arrivé gagne.
|
||||
if (id.isNotEmpty) {
|
||||
for (final raw in [channelId, ...displayNames]) {
|
||||
final loose = _looseIndexKey(looseKey(raw));
|
||||
if (loose.isNotEmpty) aliases.putIfAbsent(loose, () => id);
|
||||
}
|
||||
}
|
||||
channelId = null;
|
||||
case 'programme':
|
||||
if (programmeChannel != null &&
|
||||
start != null &&
|
||||
stop != null &&
|
||||
stop.isAfter(cutoff)) {
|
||||
stop.isAfter(cutoff) &&
|
||||
start.isBefore(limit)) {
|
||||
final key = normalizeKey(programmeChannel);
|
||||
if (key.isNotEmpty) {
|
||||
byChannel.putIfAbsent(key, () => []).add(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/asset_failure_cache.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFailureCache', () {
|
||||
test('un hôte inconnu est considéré joignable', () {
|
||||
final cache = AssetFailureCache();
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('un échec isolé ne coupe pas l’hôte', () {
|
||||
// Un 5xx passager ne doit pas priver l'utilisateur de ses logos
|
||||
// pendant plusieurs minutes.
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('coupe l’hôte après le seuil d’échecs consécutifs', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
cache.recordFailure('cdn.example');
|
||||
}
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
});
|
||||
|
||||
test('un succès remet le compteur à zéro', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordSuccess('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('la coupure expire après le TTL', () {
|
||||
var now = DateTime(2026, 9, 19, 9);
|
||||
final cache = AssetFailureCache(
|
||||
threshold: 1,
|
||||
ttl: const Duration(minutes: 5),
|
||||
clock: () => now,
|
||||
);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
|
||||
now = now.add(const Duration(minutes: 6));
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('les hôtes sont indépendants', () {
|
||||
final cache = AssetFailureCache(threshold: 1);
|
||||
cache.recordFailure('mort.example');
|
||||
expect(cache.isDown('mort.example'), isTrue);
|
||||
expect(cache.isDown('vivant.example'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/asset_versioning.dart';
|
||||
|
||||
void main() {
|
||||
group('versionAssetRefs', () {
|
||||
test('versionne la balise script du lecteur', () {
|
||||
expect(
|
||||
versionAssetRefs(
|
||||
'<script src="xf-player-core.js"></script>',
|
||||
{'xf-player-core.js': 'abc123'},
|
||||
),
|
||||
'<script src="xf-player-core.js?v=abc123"></script>',
|
||||
);
|
||||
});
|
||||
|
||||
test('versionne mainJsPath dans la config de build Flutter', () {
|
||||
expect(
|
||||
versionAssetRefs(
|
||||
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}',
|
||||
{'main.dart.js': 'f00'},
|
||||
),
|
||||
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('ne touche ni un nom qui ne fait que contenir l\'asset ni une ref déjà versionnée', () {
|
||||
const html = '<script src="vendor/xf-player-core.js"></script>'
|
||||
'<script src="xf-player-core.js?v=old"></script>';
|
||||
expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html);
|
||||
});
|
||||
|
||||
test('guillemets simples acceptés', () {
|
||||
expect(
|
||||
versionAssetRefs("<link href='xf-player.css'>", {'xf-player.css': '1'}),
|
||||
"<link href='xf-player.css?v=1'>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('contentVersion', () {
|
||||
test('stable pour un même contenu, différente sinon', () {
|
||||
expect(contentVersion([1, 2, 3]), contentVersion([1, 2, 3]));
|
||||
expect(contentVersion([1, 2, 3]), isNot(contentVersion([1, 2, 4])));
|
||||
expect(contentVersion([1, 2, 3]), hasLength(12));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/epg_api.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) :
|
||||
/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne
|
||||
@@ -71,4 +78,252 @@ void main() {
|
||||
expect(result['programmes'], isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('handleGetEpg', () {
|
||||
final playlist = PlaylistConfig(
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
dns: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
String xmltvDump(DateTime start, DateTime stop) {
|
||||
String stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
return '''
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<tv>
|
||||
<channel id="France2.fr"><display-name>FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
Request requestFor(String channelId) =>
|
||||
Request('GET', Uri.parse('http://localhost/api/epg/$channelId'));
|
||||
|
||||
test('sert le guide depuis le dump XMLTV du panneau, sans appel par '
|
||||
'chaîne', () async {
|
||||
// Un appel `player_api` par chaîne coûte plusieurs secondes chez le
|
||||
// fournisseur : le dump du panneau couvre toutes les chaînes d'un coup.
|
||||
final now = DateTime.now().toUtc();
|
||||
final actions = <String>[];
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response(
|
||||
xmltvDump(now.subtract(const Duration(minutes: 5)),
|
||||
now.add(const Duration(minutes: 25))),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'stream_id': 845452,
|
||||
'name': 'FRANCE 2',
|
||||
'epg_channel_id': 'France2.fr',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('845452'), '845452');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
expect(response.headers['X-Epg-Source'], 'panel-xmltv');
|
||||
// Seule la table des chaînes est interrogée : aucune action EPG.
|
||||
expect(actions, ['get_live_streams']);
|
||||
});
|
||||
|
||||
test('retombe sur player_api quand le dump ne couvre pas la chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
// Heure locale volontairement : le panneau émet des horodatages sans
|
||||
// fuseau, que le serveur relit comme de l'heure locale.
|
||||
final future = DateTime.now().add(const Duration(minutes: 10));
|
||||
String panelStamp(DateTime d) =>
|
||||
d.toIso8601String().substring(0, 19).replaceFirst('T', ' ');
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response('<tv></tv>', 200);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
if (action == 'get_simple_data_table') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'epg_listings': [
|
||||
{
|
||||
'title': base64Encode(utf8.encode('Match')),
|
||||
'description': '',
|
||||
'start': panelStamp(future),
|
||||
'end': panelStamp(future.add(const Duration(hours: 2))),
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('999'), '999');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect((body['programmes'] as List).single['title'], 'Match');
|
||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||
expect(actions, contains('get_simple_data_table'));
|
||||
});
|
||||
|
||||
// Dump externe couvrant TF1, et panneau dont le xmltv.php est vide.
|
||||
String externalDump() {
|
||||
final now = DateTime.now().toUtc();
|
||||
return xmltvDump(now, now.add(const Duration(hours: 1)))
|
||||
.replaceAll('France2.fr', 'TF1.fr')
|
||||
.replaceAll('FRANCE 2', 'TF1');
|
||||
}
|
||||
|
||||
EpgApi apiWith(http.Client client) => EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
xmltv: XmltvEpgService(
|
||||
sourceUrls: const ['http://ext.example/epg.xml'],
|
||||
client: client,
|
||||
),
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
test('un panneau qui plante ne prive plus la chaîne du dump externe',
|
||||
() async {
|
||||
// Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide
|
||||
// externe (qui couvrait pourtant la chaîne) n'étant jamais consulté.
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
if (action == 'get_live_streams') {
|
||||
// Octets UTF-8 et JSON sans charset annoncé, comme le panneau réel.
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode([
|
||||
{'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''},
|
||||
])),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
throw http.ClientException('Connection closed', url);
|
||||
});
|
||||
|
||||
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
});
|
||||
|
||||
test('le dump externe passe avant l\'appel lent chaîne par chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||
expect(actions, isNot(contains('get_simple_data_table')));
|
||||
});
|
||||
|
||||
test('la grille entière partage un seul téléchargement de la table',
|
||||
() async {
|
||||
// 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant
|
||||
// de `get_live_streams` (7 Mo en prod) partaient en parallèle.
|
||||
var liveStreamsCalls = 0;
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
if (action == 'get_live_streams') {
|
||||
liveStreamsCalls++;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = apiWith(client);
|
||||
final responses = await Future.wait([
|
||||
for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'),
|
||||
]);
|
||||
expect(responses.every((r) => r.statusCode == 200), isTrue);
|
||||
expect(liveStreamsCalls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../services/gpu_fallback.dart';
|
||||
|
||||
void main() {
|
||||
group('isGpuFailure', () {
|
||||
test('detects NVENC out-of-memory', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: '
|
||||
'out of memory (10)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects missing NVENC device', () {
|
||||
expect(
|
||||
isGpuFailure('[h264_nvenc @ 0x1] No capable devices found'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects CUDA errors', () {
|
||||
expect(
|
||||
isGpuFailure('cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects encoder opening failure', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'Error while opening encoder for output stream #0:0 - maybe '
|
||||
'incorrect parameters such as bit_rate, rate, width or height',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores upstream failures', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 '
|
||||
'Not Found',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores timeouts and null', () {
|
||||
expect(isGpuFailure('Timeout waiting for transcoder'), isFalse);
|
||||
expect(isGpuFailure(null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('GpuHealth', () {
|
||||
test('available until a failure, then again after cooldown', () {
|
||||
var now = DateTime.utc(2026, 10, 8, 20, 0);
|
||||
final health = GpuHealth(
|
||||
cooldown: const Duration(minutes: 2),
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
expect(health.available, isTrue);
|
||||
|
||||
health.markFailed();
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(minutes: 1, seconds: 59));
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
expect(health.available, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/logo_api.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Catalogue qui connaît uniquement `rmc-sport-2` et sert [bytes].
|
||||
LogoCatalog _catalog(List<int> bytes, {void Function()? onUse}) {
|
||||
return LogoCatalog(
|
||||
client: MockClient((request) async {
|
||||
onUse?.call();
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes(bytes, 200);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Request _logoRequest(String src, String name) => Request(
|
||||
'GET',
|
||||
Uri.http('localhost:8089', '/api/logo', {'src': src, 'name': name}),
|
||||
headers: {'cookie': 'session=abc'},
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LogoApi', () {
|
||||
test('sert le logo du panneau quand il répond', () async {
|
||||
Request? forwarded;
|
||||
var catalogUsed = false;
|
||||
final api = LogoApi(
|
||||
(request) {
|
||||
forwarded = request;
|
||||
return Response.ok(
|
||||
[9, 9],
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
},
|
||||
_catalog([1], onUse: () => catalogUsed = true),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(await response.read().expand((b) => b).toList(), [9, 9]);
|
||||
expect(response.headers['cache-control'], contains('max-age=86400'));
|
||||
expect(catalogUsed, isFalse);
|
||||
// Le proxy reçoit l'URL au format attendu et la session du client.
|
||||
expect(forwarded!.url.path, 'api/xtream/http://picons.example/logos/1.png');
|
||||
expect(forwarded!.headers['cookie'], 'session=abc');
|
||||
});
|
||||
|
||||
test('retombe sur le catalogue quand le panneau échoue', () async {
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['content-type'], 'image/png');
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('ignore une réponse panneau qui n’est pas une image', () async {
|
||||
// Page HTML de maintenance servie en 200 : pas un logo.
|
||||
final api = LogoApi(
|
||||
(_) => Response.ok('<html>', headers: {'content-type': 'text/html'}),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('rend une erreur mise en cache quand rien ne correspond', () async {
|
||||
// Un statut d'erreur laisse le client afficher son icône de repli ; une
|
||||
// image vide donnait une tuile blanche. 410 et non 404 : la Cascade du
|
||||
// serveur rattrape les 404 et perdrait le cache-control.
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([1]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - INCONNUE'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 410);
|
||||
expect(response.headers['cache-control'], contains('max-age=600'));
|
||||
});
|
||||
|
||||
test('cherche par nom quand le panneau ne fournit pas de logo', () async {
|
||||
var proxyCalled = false;
|
||||
final api = LogoApi(
|
||||
(_) {
|
||||
proxyCalled = true;
|
||||
return Response.notFound(null);
|
||||
},
|
||||
_catalog([7]),
|
||||
);
|
||||
|
||||
final response = await api.handle(_logoRequest('', 'FR - RMC SPORT 2'));
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(proxyCalled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans `-fr.png`).
|
||||
const _france = {
|
||||
'bein-sports-1-french',
|
||||
'bein-sports-2-french',
|
||||
'bein-sports',
|
||||
'canal-plus',
|
||||
'canal-plus-sport',
|
||||
'canal-plus-sport-360',
|
||||
'eurosport-1',
|
||||
'france-2',
|
||||
'france-24',
|
||||
'lequipe',
|
||||
'rmc-sport-1',
|
||||
'rmc-sport-2',
|
||||
'rmc-sport-access-1',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('matchLogoKey', () {
|
||||
String? match(String name) => matchLogoKey(name, _france);
|
||||
|
||||
test('correspondance exacte après nettoyage du nom panneau', () {
|
||||
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
|
||||
expect(match('FR - RMC SPORT 2 FHD'), 'rmc-sport-2');
|
||||
expect(match('FR: France 2 HD'), 'france-2');
|
||||
expect(match('|FR| EUROSPORT 1 UHD'), 'eurosport-1');
|
||||
});
|
||||
|
||||
test('traduit le « + » de Canal+', () {
|
||||
expect(match('FR - CANAL+ SPORT FHD'), 'canal-plus-sport');
|
||||
expect(match('FR - CANAL+'), 'canal-plus');
|
||||
});
|
||||
|
||||
test('accepte une variante plus longue du même identifiant', () {
|
||||
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
|
||||
expect(match('FR - BEIN SPORTS 1 FHD'), 'bein-sports-1-french');
|
||||
});
|
||||
|
||||
test('retombe sur la marque quand le numéro de canal est inconnu', () {
|
||||
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
|
||||
// celui de la marque vaut mieux qu'une tuile vide.
|
||||
expect(match('FR - RMC SPORT LIVE 5 FHD'), 'rmc-sport-1');
|
||||
expect(match('FR - CANAL+ SPORT 2'), 'canal-plus-sport');
|
||||
});
|
||||
|
||||
test('ne devine pas sur un seul mot', () {
|
||||
// « France » seul ne doit pas donner le logo de France 24.
|
||||
expect(match('FR - FRANCE'), isNull);
|
||||
});
|
||||
|
||||
test('rend null quand rien ne correspond', () {
|
||||
expect(match('FR - DAZN FHD'), isNull);
|
||||
expect(match(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('countryFolderFor', () {
|
||||
test('lit le préfixe pays du nom', () {
|
||||
expect(countryFolderFor('FR - TF1'), 'france');
|
||||
expect(countryFolderFor('BE: RTL TVI'), 'belgium');
|
||||
expect(countryFolderFor('|UK| BBC ONE'), 'united-kingdom');
|
||||
});
|
||||
|
||||
test('France par défaut sans préfixe reconnu', () {
|
||||
expect(countryFolderFor('TF1 HD'), 'france');
|
||||
});
|
||||
});
|
||||
|
||||
group('LogoCatalog', () {
|
||||
test('télécharge le logo apparié et le garde en cache', () async {
|
||||
final requested = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
requested.add(request.url.toString());
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
{'name': 'README.md', 'download_url': 'https://raw.example/r'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes([1, 2, 3], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
final first = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
final second = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
|
||||
expect(first, [1, 2, 3]);
|
||||
expect(second, [1, 2, 3]);
|
||||
// Une liste et un logo, pas un aller-retour par affichage.
|
||||
expect(requested, hasLength(2));
|
||||
});
|
||||
|
||||
test('rend null sans correspondance, sans télécharger d’image', () async {
|
||||
var downloads = 0;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
downloads++;
|
||||
return http.Response.bytes([1], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
expect(await catalog.logoFor('FR - CHAINE INCONNUE'), isNull);
|
||||
expect(downloads, 0);
|
||||
});
|
||||
|
||||
test('espace les tentatives quand la liste est injoignable', () async {
|
||||
var listings = 0;
|
||||
final client = MockClient((request) async {
|
||||
listings++;
|
||||
return http.Response('rate limited', 403);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
await catalog.logoFor('FR - RMC SPORT 2');
|
||||
await catalog.logoFor('FR - RMC SPORT 1');
|
||||
|
||||
expect(listings, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/recording_playlist.dart';
|
||||
|
||||
void main() {
|
||||
group('parseRecordingStart', () {
|
||||
test('reads a positive position in seconds', () {
|
||||
expect(parseRecordingStart('2700'), 2700);
|
||||
});
|
||||
|
||||
test('truncates fractions of a second', () {
|
||||
expect(parseRecordingStart('2700.9'), 2700);
|
||||
});
|
||||
|
||||
test('falls back to the beginning when absent or unusable', () {
|
||||
expect(parseRecordingStart(null), 0);
|
||||
expect(parseRecordingStart(''), 0);
|
||||
expect(parseRecordingStart('abc'), 0);
|
||||
expect(parseRecordingStart('-30'), 0);
|
||||
expect(parseRecordingStart('NaN'), 0);
|
||||
expect(parseRecordingStart('Infinity'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingOffsetKey', () {
|
||||
test('round-trips through its parser', () {
|
||||
for (final start in [0, 1, 2700, 86400]) {
|
||||
expect(parseRecordingOffsetKey(recordingOffsetKey(start)), start);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects keys that are not an offset', () {
|
||||
expect(parseRecordingOffsetKey('t'), isNull);
|
||||
expect(parseRecordingOffsetKey('segment_000.ts'), isNull);
|
||||
expect(parseRecordingOffsetKey('..'), isNull);
|
||||
expect(parseRecordingOffsetKey('t-30'), isNull);
|
||||
expect(parseRecordingOffsetKey('tabc'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rewriteRecordingPlaylist', () {
|
||||
const playlist = '#EXTM3U\n'
|
||||
'#EXT-X-VERSION:3\n'
|
||||
'#EXT-X-TARGETDURATION:4\n'
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_000.ts\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_001.ts\n';
|
||||
|
||||
test('binds every segment to its own session directory', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't2700');
|
||||
expect(rewritten, contains('t2700/segment_000.ts'));
|
||||
expect(rewritten, contains('t2700/segment_001.ts'));
|
||||
expect(rewritten, isNot(contains('\nsegment_')));
|
||||
});
|
||||
|
||||
test('leaves tags untouched', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't0');
|
||||
expect(rewritten, startsWith('#EXTM3U\n#EXT-X-VERSION:3\n'));
|
||||
expect(rewritten, contains('#EXT-X-PLAYLIST-TYPE:EVENT'));
|
||||
expect('#EXTINF:4.000000,'.allMatches(rewritten).length, 2);
|
||||
});
|
||||
|
||||
test('is idempotent on an already rewritten playlist', () {
|
||||
final once = rewriteRecordingPlaylist(playlist, 't60');
|
||||
expect(rewriteRecordingPlaylist(once, 't60'), once);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
|
||||
void main() {
|
||||
group('pipeWithBackpressure', () {
|
||||
test('relaie les données au client', () async {
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () {});
|
||||
|
||||
final received = <List<int>>[];
|
||||
final done = piped.listen(received.add).asFuture<void>();
|
||||
|
||||
source.add([1, 2, 3]);
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(received, [
|
||||
[1, 2, 3]
|
||||
]);
|
||||
});
|
||||
|
||||
test('propage la pause du client vers la source', () async {
|
||||
// Sans cette propagation, un client plus lent que le flux laissait
|
||||
// FFmpeg produire à pleine vitesse et le serveur empilait les paquets
|
||||
// en mémoire : latence qui dérive et lecture qui saccade.
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
final source = StreamController<List<int>>(
|
||||
onPause: () => paused = true,
|
||||
onResume: () => resumed = true,
|
||||
);
|
||||
|
||||
final subscription = pipeWithBackpressure(
|
||||
source.stream,
|
||||
onStop: () {},
|
||||
).listen((_) {});
|
||||
|
||||
subscription.pause();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(paused, isTrue);
|
||||
|
||||
subscription.resume();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(resumed, isTrue);
|
||||
|
||||
await subscription.cancel();
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand le client se déconnecte', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final subscription =
|
||||
pipeWithBackpressure(source.stream, onStop: () => stopped++)
|
||||
.listen((_) {});
|
||||
|
||||
await subscription.cancel();
|
||||
|
||||
expect(stopped, 1);
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand la source se termine', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () => stopped++);
|
||||
final done = piped.listen((_) {}).asFuture<void>();
|
||||
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(stopped, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../api/upstream_probe.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
void main() {
|
||||
final playlist = PlaylistConfig(
|
||||
id: 'p',
|
||||
name: 'n',
|
||||
dns: 'http://panel.test',
|
||||
username: 'secretuser',
|
||||
password: 'secretpass',
|
||||
createdAt: DateTime(2026),
|
||||
);
|
||||
|
||||
// Panneau simulé : redirige vers un serveur de diffusion avec jeton.
|
||||
final client = MockClient.streaming((request, _) async {
|
||||
final path = request.url.path;
|
||||
if (request.url.host == 'panel.test') {
|
||||
return http.StreamedResponse(
|
||||
const Stream.empty(),
|
||||
302,
|
||||
headers: {
|
||||
'location': 'http://lb.test$path?token=secrettoken',
|
||||
},
|
||||
);
|
||||
}
|
||||
if (path.endsWith('.m3u8')) {
|
||||
return http.StreamedResponse(
|
||||
Stream.value(utf8.encode(
|
||||
'#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg1.ts\n'
|
||||
'#EXTINF:6,\nseg2.ts\n',
|
||||
)),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.StreamedResponse(Stream.value(List.filled(2000, 0x47)), 200);
|
||||
});
|
||||
|
||||
test('chronomètre .ts et .m3u8 en suivant les redirections', () async {
|
||||
final result = await probeUpstream(playlist, '123', client: client);
|
||||
final ts = result['ts'] as Map;
|
||||
expect((ts['hops'] as List).map((h) => (h as Map)['status']), [302, 200]);
|
||||
expect(ts['firstByteMs'], isNotNull);
|
||||
|
||||
final hls = result['m3u8'] as Map;
|
||||
expect(hls['segments'], 2);
|
||||
expect(hls['targetDuration'], '6');
|
||||
expect((hls['segment'] as Map)['firstByteMs'], isNotNull);
|
||||
});
|
||||
|
||||
test('aucun identifiant ni jeton dans la réponse', () async {
|
||||
final json = jsonEncode(await probeUpstream(playlist, '123', client: client));
|
||||
expect(json, isNot(contains('secretuser')));
|
||||
expect(json, isNot(contains('secretpass')));
|
||||
expect(json, isNot(contains('secrettoken')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/upstream_slots.dart';
|
||||
|
||||
void main() {
|
||||
group('UpstreamSlots.makeRoom', () {
|
||||
late UpstreamSlots slots;
|
||||
late List<String> released;
|
||||
var clock = DateTime(2026, 1, 1);
|
||||
|
||||
void add(String id, String account) {
|
||||
clock = clock.add(const Duration(seconds: 1));
|
||||
slots.register(id: id, account: account, release: () => released.add(id));
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
slots = UpstreamSlots(now: () => clock);
|
||||
released = [];
|
||||
});
|
||||
|
||||
test('compte à 1 connexion : le flux précédent est coupé', () {
|
||||
add('live_1_source', 'acc');
|
||||
final freed = slots.makeRoom('acc', max: 1, keep: 'live_2_source');
|
||||
expect(freed, ['live_1_source']);
|
||||
expect(released, ['live_1_source']);
|
||||
expect(slots.countFor('acc'), 0);
|
||||
});
|
||||
|
||||
test('la session demandée elle-même n\'est jamais coupée', () {
|
||||
add('live_1_source', 'acc');
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'live_1_source'), isEmpty);
|
||||
expect(released, isEmpty);
|
||||
});
|
||||
|
||||
test('les plus anciennes partent d\'abord, dans la limite du quota', () {
|
||||
add('a', 'acc');
|
||||
add('b', 'acc');
|
||||
add('c', 'acc');
|
||||
// 3 connexions max : il faut une place pour la nouvelle → 1 seule coupée.
|
||||
expect(slots.makeRoom('acc', max: 3, keep: 'new'), ['a']);
|
||||
expect(slots.countFor('acc'), 2);
|
||||
});
|
||||
|
||||
test('un autre compte n\'est jamais touché', () {
|
||||
add('x', 'other');
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
|
||||
expect(slots.countFor('other'), 1);
|
||||
});
|
||||
|
||||
test('unregister avec un jeton périmé ne libère pas la session relancée', () {
|
||||
final old = slots.register(id: 's', account: 'acc', release: () {});
|
||||
slots.register(id: 's', account: 'acc', release: () {});
|
||||
slots.unregister('s', token: old);
|
||||
expect(slots.countFor('acc'), 1);
|
||||
});
|
||||
|
||||
test('un flux encore regardé n\'est jamais coupé (pas de ping-pong entre deux lecteurs)', () {
|
||||
slots.register(
|
||||
id: 'watched',
|
||||
account: 'acc',
|
||||
release: () => released.add('watched'),
|
||||
isActive: () => true,
|
||||
);
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
|
||||
expect(released, isEmpty);
|
||||
expect(slots.countFor('acc'), 1);
|
||||
});
|
||||
|
||||
test('seuls les orphelins partent, même plus récents qu\'un flux regardé', () {
|
||||
slots.register(
|
||||
id: 'watched',
|
||||
account: 'acc',
|
||||
release: () => released.add('watched'),
|
||||
isActive: () => true,
|
||||
);
|
||||
add('orphan', 'acc');
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'new'), ['orphan']);
|
||||
expect(released, ['orphan']);
|
||||
});
|
||||
|
||||
test('quota inconnu ou nul : rien n\'est coupé', () {
|
||||
add('a', 'acc');
|
||||
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('AccountLimits', () {
|
||||
PlaylistConfig playlist() => PlaylistConfig(
|
||||
id: 'p',
|
||||
name: 'n',
|
||||
dns: 'http://panel.test',
|
||||
username: 'u',
|
||||
password: 'x',
|
||||
createdAt: DateTime(2026),
|
||||
);
|
||||
|
||||
test('ne fait jamais attendre un flux : repli immédiat, valeur réelle ensuite', () async {
|
||||
final pending = Completer<int?>();
|
||||
final limits = AccountLimits(fetch: (_) => pending.future);
|
||||
|
||||
// Le panneau répond lentement (4 s mesurées en prod) : le premier flux
|
||||
// part tout de suite sur la valeur de repli.
|
||||
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||
|
||||
pending.complete(3);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(await limits.maxFor(playlist()), 3);
|
||||
});
|
||||
|
||||
test('valeur périmée servie pendant le rafraîchissement', () async {
|
||||
var calls = 0;
|
||||
final limits = AccountLimits(
|
||||
ttl: Duration.zero,
|
||||
fetch: (_) async => ++calls == 1 ? 2 : 5,
|
||||
);
|
||||
await limits.maxFor(playlist());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// Périmée (ttl nul) : la valeur connue sort sans attendre la nouvelle.
|
||||
expect(await limits.maxFor(playlist()), 2);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(await limits.maxFor(playlist()), 5);
|
||||
});
|
||||
|
||||
test('panneau en échec : repli, sans exception', () async {
|
||||
final limits = AccountLimits(fetch: (_) async => throw Exception('down'));
|
||||
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseMaxConnections', () {
|
||||
test('lit la valeur texte renvoyée par player_api', () {
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': '1'},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepte un entier', () {
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': 3},
|
||||
}),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('réponse inexploitable → null', () {
|
||||
expect(parseMaxConnections({'user_info': {}}), isNull);
|
||||
expect(parseMaxConnections('oops'), isNull);
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': '0'},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/streaming_handler.dart';
|
||||
|
||||
void main() {
|
||||
group('vodContainerExtension', () {
|
||||
test('garde l\'extension réelle du catalogue', () {
|
||||
expect(vodContainerExtension('mp4'), 'mp4');
|
||||
expect(vodContainerExtension('MKV'), 'mkv');
|
||||
expect(vodContainerExtension('avi'), 'avi');
|
||||
});
|
||||
|
||||
test('ancien client sans ext : mkv, comme avant', () {
|
||||
expect(vodContainerExtension(null), 'mkv');
|
||||
expect(vodContainerExtension(''), 'mkv');
|
||||
});
|
||||
|
||||
test('refuse tout ce qui pourrait sortir du nom de fichier', () {
|
||||
expect(vodContainerExtension('mp4/../x'), 'mkv');
|
||||
expect(vodContainerExtension('mp4?a=b'), 'mkv');
|
||||
expect(vodContainerExtension('toolongext'), 'mkv');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de
|
||||
/// l'abonné y figurent en clair, comme en production.
|
||||
const _panelUrl =
|
||||
'http://panel.example:8080/xmltv.php?username=john&password=hunter2';
|
||||
|
||||
/// Exécute [body] en capturant tout ce qui passe par `print`.
|
||||
Future<List<String>> _capturePrints(Future<void> Function() body) async {
|
||||
final lines = <String>[];
|
||||
await runZoned(
|
||||
body,
|
||||
zoneSpecification: ZoneSpecification(
|
||||
print: (self, parent, zone, line) => lines.add(line),
|
||||
),
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('XmltvEpgService — journalisation', () {
|
||||
test('masque les identifiants de l\'URL quand la source répond', () async {
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [_panelUrl],
|
||||
client: MockClient(
|
||||
(_) async => http.Response('<?xml version="1.0"?><tv></tv>', 200),
|
||||
),
|
||||
);
|
||||
|
||||
final lines = await _capturePrints(service.ensureFresh);
|
||||
final xmltvLines = lines.where((l) => l.startsWith('[XmltvEpg]'));
|
||||
|
||||
expect(xmltvLines, isNotEmpty);
|
||||
for (final line in xmltvLines) {
|
||||
expect(line, isNot(contains('john')));
|
||||
expect(line, isNot(contains('hunter2')));
|
||||
}
|
||||
expect(
|
||||
lines,
|
||||
contains(contains('username=***&password=***')),
|
||||
);
|
||||
});
|
||||
|
||||
test('masque les identifiants de l\'URL et de l\'exception en cas d\'échec',
|
||||
() async {
|
||||
// `ClientException` recopie l'URL demandée dans son message : c'est
|
||||
// par là que les identifiants fuyaient aussi.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [_panelUrl],
|
||||
client: MockClient(
|
||||
(request) async => throw http.ClientException(
|
||||
'Connection refused',
|
||||
request.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final lines = await _capturePrints(service.ensureFresh);
|
||||
final failure = lines.firstWhere(
|
||||
(l) => l.contains('échec'),
|
||||
orElse: () => fail('aucune ligne d\'échec journalisée : $lines'),
|
||||
);
|
||||
|
||||
expect(failure, isNot(contains('john')));
|
||||
expect(failure, isNot(contains('hunter2')));
|
||||
expect(failure, contains('username=***&password=***'));
|
||||
expect(failure, contains('Connection refused'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
@@ -24,6 +29,13 @@ String _fixture(DateTime start, DateTime stop) {
|
||||
''';
|
||||
}
|
||||
|
||||
String _stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = XmltvEpgService(sourceUrls: const []);
|
||||
|
||||
@@ -47,6 +59,117 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('nameKeys', () {
|
||||
// Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas
|
||||
// d'epg_channel_id, seul leur nom permet de retrouver le guide.
|
||||
test('retire le préfixe pays et les marqueurs de qualité', () {
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1'));
|
||||
expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4'));
|
||||
expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6'));
|
||||
expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte'));
|
||||
expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory'));
|
||||
});
|
||||
|
||||
test('propose aussi la forme identifiant « nom.pays »', () {
|
||||
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr'));
|
||||
});
|
||||
|
||||
test('garde la variante décalée distincte de la chaîne principale', () {
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1')));
|
||||
});
|
||||
|
||||
test('ne réduit pas un nom court à rien', () {
|
||||
expect(XmltvEpgService.nameKeys('M6'), contains('m6'));
|
||||
expect(XmltvEpgService.nameKeys('HD'), isNot(contains('')));
|
||||
});
|
||||
});
|
||||
|
||||
group('correspondances élargies (chaînes vides mesurées en prod)', () {
|
||||
XmltvEpgService serviceWith(String dump) => XmltvEpgService(
|
||||
sourceUrls: const ['http://ext/epg.xml'],
|
||||
client: MockClient((_) async => http.Response.bytes(
|
||||
// Octets UTF-8 : les ids réels portent des accents.
|
||||
const Utf8Encoder().convert(dump),
|
||||
200,
|
||||
)),
|
||||
);
|
||||
|
||||
String channel(String id, String name, String title, DateTime start) => '''
|
||||
<channel id="$id"><display-name>$name</display-name></channel>
|
||||
<programme start="${_stamp(start)}" stop="${_stamp(start.add(const Duration(hours: 1)))}" channel="$id"><title>$title</title></programme>''';
|
||||
|
||||
test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - F3 ALPES HD');
|
||||
expect(hits.single.title, 'JT Alpes');
|
||||
});
|
||||
|
||||
test('« AB3 » trouve AB3.Channel.fr (clé souple)', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}</tv>');
|
||||
final hits = await svc.programmesFor('AB3.fr', displayName: 'FR - AB3 FHD');
|
||||
expect(hits.single.title, 'Série');
|
||||
});
|
||||
|
||||
test('« TF1 +1 » reprend le guide de TF1 décalé d\'une heure', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('TF1.fr', 'TF1', 'Le 13h', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 +1 FHD');
|
||||
expect(hits.single.title, 'Le 13h');
|
||||
expect(hits.single.start, now.add(const Duration(hours: 1)).copyWith(microsecond: 0, millisecond: 0));
|
||||
});
|
||||
|
||||
test('une chaîne +1 qui a son propre guide le garde', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>'
|
||||
'${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}'
|
||||
'${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - BOOMERANG +1');
|
||||
expect(hits.single.title, 'Décalé');
|
||||
});
|
||||
});
|
||||
|
||||
group('programmesFor par nom', () {
|
||||
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
|
||||
() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
String stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
final dump = '''
|
||||
<tv>
|
||||
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
|
||||
<programme start="${stamp(now)}" stop="${stamp(now.add(const Duration(hours: 1)))}" channel="TF1.fr">
|
||||
<title>Le 13h</title>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
final svc = XmltvEpgService(
|
||||
sourceUrls: const ['http://ext/epg.xml'],
|
||||
client: MockClient((_) async => http.Response(dump, 200)),
|
||||
);
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉');
|
||||
expect(hits.single.title, 'Le 13h');
|
||||
});
|
||||
|
||||
test('indexe chaque display-name, pas seulement le premier', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
final index = service.parseForTest('''
|
||||
<tv>
|
||||
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
|
||||
<programme start="${_stamp(now)}" stop="${_stamp(now.add(const Duration(hours: 1)))}" channel="x.fr"><title>T</title></programme>
|
||||
</tv>
|
||||
''');
|
||||
expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseXmltvDate', () {
|
||||
test('applique le décalage horaire annoncé', () {
|
||||
expect(
|
||||
@@ -101,4 +224,129 @@ void main() {
|
||||
expect(index, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ensureFresh', () {
|
||||
test('ne retélécharge pas tant que l’index est frais', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
_fixture(now, now.add(const Duration(minutes: 30))),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isTrue);
|
||||
});
|
||||
|
||||
test('espace les tentatives après un échec', () async {
|
||||
// Sans ce recul, une source morte était retéléchargée à chaque
|
||||
// consultation du guide : une requête sortante par chaîne affichée.
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
retryBackoff: const Duration(minutes: 15),
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('nope', 500);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('réactivité du serveur', () {
|
||||
test('l’indexation ne gèle pas la boucle d’événements', () async {
|
||||
// Le serveur n'a qu'un isolate : décoder et parser un dump de
|
||||
// plusieurs milliers de chaînes dessus bloquait tout le reste pendant
|
||||
// des secondes — y compris le relais `turbo.ts` des flux en cours,
|
||||
// d'où des coupures systématiques au démarrage de la lecture.
|
||||
final now = DateTime.now().toUtc();
|
||||
String stamp(DateTime d) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${d.year}${two(d.month)}${two(d.day)}'
|
||||
'${two(d.hour)}${two(d.minute)}${two(d.second)} +0000';
|
||||
}
|
||||
|
||||
// Volume d'un vrai dump de panneau : ~7000 chaînes, résumés de
|
||||
// plusieurs phrases.
|
||||
final desc = 'Résumé du programme, avec assez de texte pour peser '
|
||||
'comme un vrai guide. ' * 4;
|
||||
final xml = StringBuffer('<?xml version="1.0"?><tv>');
|
||||
for (var c = 0; c < 7000; c++) {
|
||||
xml.write('<channel id="Chaine.$c.fr">'
|
||||
'<display-name>FR - CHAINE $c</display-name></channel>');
|
||||
for (var p = 0; p < 30; p++) {
|
||||
final start = now.add(Duration(minutes: 30 * p));
|
||||
final stop = start.add(const Duration(minutes: 30));
|
||||
xml.write('<programme start="${stamp(start)}" '
|
||||
'stop="${stamp(stop)}" channel="Chaine.$c.fr">'
|
||||
'<title>Programme $p</title><desc>$desc</desc>'
|
||||
'</programme>');
|
||||
}
|
||||
}
|
||||
xml.write('</tv>');
|
||||
final body = xml.toString();
|
||||
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async => http.Response(body, 200)),
|
||||
);
|
||||
|
||||
// Un « battement » toutes les 10 ms : le plus long écart observé est
|
||||
// le temps pendant lequel la boucle d'événements est restée bloquée.
|
||||
var last = DateTime.now();
|
||||
var worstGap = Duration.zero;
|
||||
final heartbeat = Timer.periodic(const Duration(milliseconds: 10), (_) {
|
||||
final now = DateTime.now();
|
||||
final gap = now.difference(last);
|
||||
if (gap > worstGap) worstGap = gap;
|
||||
last = now;
|
||||
});
|
||||
|
||||
await service.ensureFresh();
|
||||
heartbeat.cancel();
|
||||
// Le dernier blocage n'est suivi d'aucun battement : le compter aussi.
|
||||
final tail = DateTime.now().difference(last);
|
||||
if (tail > worstGap) worstGap = tail;
|
||||
|
||||
expect(service.channelCount, greaterThanOrEqualTo(7000));
|
||||
expect(
|
||||
worstGap,
|
||||
lessThan(const Duration(milliseconds: 250)),
|
||||
reason: 'boucle d’événements gelée ${worstGap.inMilliseconds} ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('horizon', () {
|
||||
test('écarte les programmes au-delà de l’horizon d’indexation', () {
|
||||
// Un dump national couvre sept jours : tout garder ferait grossir
|
||||
// l'index pour un guide qui n'affiche que le programme courant.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [],
|
||||
horizon: const Duration(hours: 48),
|
||||
);
|
||||
final far = DateTime.now().toUtc().add(const Duration(days: 5));
|
||||
|
||||
expect(
|
||||
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Versionnage des assets non hachés de la build web.
|
||||
///
|
||||
/// POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
|
||||
/// gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
|
||||
/// `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
|
||||
/// en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
|
||||
/// l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
|
||||
/// déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
|
||||
/// chaque version a sa propre URL : aucun cache intermédiaire ne peut la
|
||||
/// confondre avec la précédente.
|
||||
|
||||
/// Empreinte courte et stable de [bytes].
|
||||
String contentVersion(List<int> bytes) =>
|
||||
md5.convert(bytes).toString().substring(0, 12);
|
||||
|
||||
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
|
||||
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
|
||||
///
|
||||
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
|
||||
/// référence déjà versionnée restent intactes.
|
||||
String versionAssetRefs(String content, Map<String, String> versions) {
|
||||
var out = content;
|
||||
versions.forEach((asset, version) {
|
||||
final pattern = RegExp('(["\'])${RegExp.escape(asset)}\\1');
|
||||
out = out.replaceAllMapped(
|
||||
pattern,
|
||||
(m) => '${m[1]}$asset?v=$version${m[1]}',
|
||||
);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
|
||||
/// être servis à la place des fichiers d'origine.
|
||||
///
|
||||
/// Calculé une fois au démarrage : la build web est figée dans l'image.
|
||||
Map<String, String> buildVersionedEntryPoints(String webPath) {
|
||||
String? read(String name) {
|
||||
final file = File('$webPath/$name');
|
||||
return file.existsSync() ? file.readAsStringSync() : null;
|
||||
}
|
||||
|
||||
String? versionOf(String name) {
|
||||
final file = File('$webPath/$name');
|
||||
return file.existsSync() ? contentVersion(file.readAsBytesSync()) : null;
|
||||
}
|
||||
|
||||
final rewritten = <String, String>{};
|
||||
|
||||
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
|
||||
// de main.dart.js, d'où une version calculée APRÈS réécriture.
|
||||
final bootstrap = read('flutter_bootstrap.js');
|
||||
final mainVersion = versionOf('main.dart.js');
|
||||
if (bootstrap != null && mainVersion != null) {
|
||||
final versioned =
|
||||
versionAssetRefs(bootstrap, {'main.dart.js': mainVersion});
|
||||
rewritten['flutter_bootstrap.js'] = versioned;
|
||||
final index = read('index.html');
|
||||
if (index != null) {
|
||||
rewritten['index.html'] = versionAssetRefs(index, {
|
||||
'flutter_bootstrap.js': contentVersion(utf8.encode(versioned)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final playerAssets = <String, String>{
|
||||
for (final name in const ['xf-player-core.js', 'xf-player.css'])
|
||||
if (versionOf(name) case final v?) name: v,
|
||||
};
|
||||
for (final page in const [
|
||||
'player.html',
|
||||
'player_lite.html',
|
||||
'player_mobile.html',
|
||||
]) {
|
||||
final html = read(page);
|
||||
if (html != null) rewritten[page] = versionAssetRefs(html, playerAssets);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
///
|
||||
/// Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
/// tournerait en boucle.
|
||||
class MediaProbe {
|
||||
MediaProbe._();
|
||||
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
static String ffprobePath() {
|
||||
final fromEnv = Platform.environment['FFPROBE_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) {
|
||||
return '/usr/local/bin/ffprobe';
|
||||
}
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier introuvable, conteneur sans durée).
|
||||
static Future<double?> duration(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'duration',
|
||||
args: const [
|
||||
'-show_entries', 'format=duration',
|
||||
],
|
||||
);
|
||||
final seconds = double.tryParse(value ?? '');
|
||||
if (seconds == null || !seconds.isFinite || seconds <= 0) return null;
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…),
|
||||
/// ou `null` si indéterminable.
|
||||
static Future<String?> videoCodec(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'videoCodec',
|
||||
args: const [
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
],
|
||||
);
|
||||
return (value == null || value.isEmpty) ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
static Future<String?> _probe(
|
||||
String path, {
|
||||
required String field,
|
||||
required List<String> args,
|
||||
}) async {
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = File(path).statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = '$field@$path';
|
||||
final cached = _cache[key];
|
||||
if (cached != null && cached.matches(stat)) return cached.value;
|
||||
|
||||
try {
|
||||
final result = await Process.run(ffprobePath(), [
|
||||
'-v', 'error',
|
||||
...args,
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]);
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
final output = (result.stdout as String).trim().split('\n').first.trim();
|
||||
if (output.isEmpty || output == 'N/A') return null;
|
||||
_cache[key] = _CacheEntry(stat, output);
|
||||
return output;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final int size;
|
||||
final int mtimeMs;
|
||||
final String value;
|
||||
|
||||
_CacheEntry(FileStat stat, this.value)
|
||||
: size = stat.size,
|
||||
mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
|
||||
bool matches(FileStat stat) =>
|
||||
stat.size == size &&
|
||||
stat.modified.millisecondsSinceEpoch == mtimeMs;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Relaie [source] vers le client en conservant la contre-pression.
|
||||
///
|
||||
/// POURQUOI : un `StreamController` nu ne transmet pas la pause de son
|
||||
/// abonné à la source. Quand le navigateur lit plus lentement que FFmpeg ne
|
||||
/// produit — réseau domestique, onglet en arrière-plan —, le serveur empilait
|
||||
/// donc les paquets en mémoire sans jamais ralentir le producteur : la
|
||||
/// consommation grimpe et la lecture dérive derrière le direct, ce qui se voit
|
||||
/// à l'écran comme des saccades.
|
||||
///
|
||||
/// [onStop] est appelé une seule fois, quand le client se déconnecte ou que la
|
||||
/// source se termine : c'est là qu'on tue le processus FFmpeg, sinon il en
|
||||
/// reste un par zapping.
|
||||
Stream<List<int>> pipeWithBackpressure(
|
||||
Stream<List<int>> source, {
|
||||
required void Function() onStop,
|
||||
}) {
|
||||
late final StreamController<List<int>> controller;
|
||||
late final StreamSubscription<List<int>> subscription;
|
||||
var stopped = false;
|
||||
|
||||
void stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
onStop();
|
||||
}
|
||||
|
||||
controller = StreamController<List<int>>(
|
||||
onPause: () => subscription.pause(),
|
||||
onResume: () => subscription.resume(),
|
||||
onCancel: () {
|
||||
final cancelled = subscription.cancel();
|
||||
stop();
|
||||
return cancelled;
|
||||
},
|
||||
);
|
||||
|
||||
subscription = source.listen(
|
||||
controller.add,
|
||||
onError: controller.addError,
|
||||
onDone: () {
|
||||
controller.close();
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
- MAX_CONCURRENT_RECORDINGS=2
|
||||
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
|
||||
# aucun programme actuel. Vider la variable coupe tout appel sortant.
|
||||
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz
|
||||
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz
|
||||
- NVIDIA_GPU=true
|
||||
volumes:
|
||||
- xtremflow-data:/app/data
|
||||
|
||||
+5
-2
@@ -13,8 +13,11 @@ services:
|
||||
# Pour changer: modifiez le chemin avant les deux points ":"
|
||||
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
||||
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
||||
- ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings
|
||||
- ${RECORDINGS_PATH:-./data/recordings}:/app/recordings
|
||||
environment:
|
||||
# Fuseau horaire du conteneur (affichage des logs ; les enregistrements
|
||||
# sont stockés en UTC quoi qu'il arrive)
|
||||
- TZ=${TZ:-Europe/Paris}
|
||||
# Origine externe autorisée pour CORS (optionnel — l'app est servie
|
||||
# same-origin, ne définir que si un autre domaine doit appeler l'API)
|
||||
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
|
||||
@@ -24,7 +27,7 @@ services:
|
||||
# aucun programme couvrant l'instant présent. Vider la variable coupe
|
||||
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
|
||||
# Plusieurs dumps se séparent par des virgules.
|
||||
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz}
|
||||
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
|
||||
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
|
||||
- NVIDIA_GPU=${NVIDIA_GPU:-false}
|
||||
restart: unless-stopped
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,12 @@
|
||||
# Archives — documents historiques
|
||||
|
||||
⚠️ **Les documents de ce dossier sont historiques et ne décrivent pas l'état actuel du code.**
|
||||
|
||||
Ils datent de phases de conception ou de refontes antérieures. Plusieurs se déclarent « COMPLETE » ou « production-ready » alors que le travail décrit n'a jamais été intégré (ou a été remplacé depuis) :
|
||||
|
||||
- `RECORDING_SYSTEM_UPGRADE.txt` — décrit un remplacement du scheduler par `SimpleRecorder` qui n'a jamais été branché ; le système réel est `bin/services/recording_scheduler.dart`.
|
||||
- `THEME_REDESIGN_SUMMARY.md` — la « Phase 4: Integration » n'a pas eu lieu ; les widgets qu'il liste n'existent plus.
|
||||
- `ANALYSIS_AND_IMPROVEMENTS.md` — plan d'améliorations dont une partie seulement a été réalisée.
|
||||
- `DEPLOYMENT_CHECKLIST.md` — référence des fichiers et versions de dépendances qui n'ont jamais existé dans le dépôt.
|
||||
|
||||
Pour l'état actuel du projet, se référer au `README.md` racine et au `CHANGELOG.md`.
|
||||
@@ -0,0 +1,20 @@
|
||||
/// URL du logo d'une chaîne, servie par le backend (`/api/logo`).
|
||||
///
|
||||
/// Le backend tente d'abord [streamIcon] (l'URL fournie par le panneau), puis
|
||||
/// cherche un logo par [name] quand l'hébergeur de picons du revendeur ne
|
||||
/// répond pas. Le nom est donc transmis même sans `stream_icon` : c'est
|
||||
/// souvent le seul moyen d'obtenir un logo.
|
||||
///
|
||||
/// Une chaîne sans logo trouvé reçoit un statut d'erreur : l'`errorBuilder`
|
||||
/// de l'`Image.network` appelante affiche alors son propre repli.
|
||||
String channelLogoUrl({required String streamIcon, required String name}) {
|
||||
final icon = streamIcon.trim();
|
||||
return Uri(
|
||||
path: '/api/logo',
|
||||
queryParameters: {
|
||||
if (icon.startsWith('http://') || icon.startsWith('https://'))
|
||||
'src': icon,
|
||||
'name': name,
|
||||
},
|
||||
).toString();
|
||||
}
|
||||
@@ -61,6 +61,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
bool _isMuted = false;
|
||||
bool _ignoreStatusUpdates = false;
|
||||
|
||||
/// Fin de la plage réellement navigable, en temps absolu.
|
||||
///
|
||||
/// Un enregistrement est transcodé au fil de l'eau par FFmpeg : ce qui est
|
||||
/// au-delà de ce point n'existe pas encore côté serveur. Sauter dedans
|
||||
/// suppose de relancer l'encodeur à la position visée.
|
||||
double _seekableEnd = 0;
|
||||
|
||||
/// Décalage du média courant par rapport au début du contenu : un
|
||||
/// enregistrement rouvert à 45 min est transcodé *depuis* 45 min.
|
||||
double _mediaOffset = 0;
|
||||
|
||||
/// Vitesse de lecture (hors direct).
|
||||
double _rate = 1.0;
|
||||
|
||||
/// Un rechargement du flux à une nouvelle position est en cours : les
|
||||
/// positions remontées par l'ancienne iframe ne doivent plus rien écraser.
|
||||
bool _seekReloading = false;
|
||||
|
||||
/// Incrémenté à chaque (re)création de l'iframe : deux vues de plateforme
|
||||
/// ne peuvent pas partager le même identifiant, et il faut un identifiant
|
||||
/// neuf pour que Flutter reconstruise réellement l'iframe.
|
||||
int _viewGeneration = 0;
|
||||
|
||||
/// Reflète l'état plein écran du document. Le navigateur peut en sortir
|
||||
/// sans passer par notre bouton (touche Échap), d'où l'écoute de
|
||||
/// `fullscreenchange` plutôt qu'un simple booléen basculé au clic.
|
||||
@@ -113,6 +136,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
/// tous les navigateurs visés.
|
||||
bool get _documentIsFullscreen => html.document.fullscreenElement != null;
|
||||
|
||||
/// Clé de reprise. Les enregistrements sont préfixés : leur identifiant est
|
||||
/// un UUID stocké dans le même espace de noms que les identifiants VOD.
|
||||
String get _positionKey => widget.streamType == StreamType.recording
|
||||
? 'rec_${widget.streamId}'
|
||||
: widget.streamId;
|
||||
|
||||
/// La position peut-elle avoir un sens (barre de progression, reprise) ?
|
||||
bool get _isSeekable => widget.streamType != StreamType.live;
|
||||
|
||||
void _syncFullscreenState() {
|
||||
final value = _documentIsFullscreen;
|
||||
if (mounted && value != _isFullscreen) {
|
||||
@@ -139,9 +171,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
|
||||
// Stable view ID for the instance to prevent iframe reload on rebuild
|
||||
// (orientation change). Includes quality so a quality switch forces a
|
||||
// fresh iframe.
|
||||
_viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix';
|
||||
// (orientation change). La génération change à chaque appel : sans
|
||||
// elle, relancer le flux à une autre position réutiliserait la même
|
||||
// vue de plateforme et l'iframe ne serait jamais reconstruite.
|
||||
_viewId =
|
||||
'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix-'
|
||||
'${_viewGeneration++}';
|
||||
|
||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||
String streamUrl = '';
|
||||
@@ -166,7 +201,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
quality: _quality.value,
|
||||
);
|
||||
} else if (widget.streamType == StreamType.recording) {
|
||||
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
|
||||
// FFmpeg transcode l'enregistrement séquentiellement : reprendre à
|
||||
// 45 min sans le lui dire imposerait d'attendre qu'il y arrive.
|
||||
// `?start=` lui fait démarrer l'encodage à la position demandée.
|
||||
_mediaOffset = (startTimeOverride ?? 0) > 0
|
||||
? startTimeOverride!.floorToDouble()
|
||||
: 0;
|
||||
final base = '${service.backendBaseUrl}/api/recordings/stream/'
|
||||
'$currentStreamId/playlist.m3u8';
|
||||
streamUrl =
|
||||
_mediaOffset > 0 ? '$base?start=${_mediaOffset.toInt()}' : base;
|
||||
}
|
||||
|
||||
// Store URL for Lite Player
|
||||
@@ -190,24 +234,24 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// - Live TV: Player Lite (simple TS playback with mpegts.js)
|
||||
// - VOD/Series: Player Standard (fuller controls, HLS support)
|
||||
|
||||
// Cache buster to force reload of updated player.html
|
||||
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final streamTypeParam =
|
||||
widget.streamType == StreamType.live ? 'live' : 'vod';
|
||||
final playerFile = isMobile
|
||||
? 'player_mobile.html'
|
||||
: (isLiveTV ? 'player_lite.html' : 'player.html');
|
||||
|
||||
// Inject Turbo-Start flag
|
||||
// Plus de cache-buster « &v=timestamp » : il forçait le re-téléchargement
|
||||
// et le re-parse de player.html à CHAQUE zap. Le serveur sert désormais
|
||||
// les .html en no-cache, la fraîcheur est garantie sans pénaliser le zap.
|
||||
var playerSrc =
|
||||
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
|
||||
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true';
|
||||
|
||||
if (widget.streamType == StreamType.recording) {
|
||||
playerSrc += '&is_recording=true';
|
||||
}
|
||||
|
||||
if (startTimeOverride != null) {
|
||||
// Le média commence à l'offset : le lecteur ré-ajoute ce décalage
|
||||
// pour exprimer position et recherches en temps absolu.
|
||||
if (_mediaOffset > 0) playerSrc += '&offset=${_mediaOffset.toInt()}';
|
||||
} else if (startTimeOverride != null && startTimeOverride > 0) {
|
||||
playerSrc += '&t=$startTimeOverride';
|
||||
}
|
||||
|
||||
@@ -231,12 +275,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
_isLoading = false;
|
||||
_isPlaying = true;
|
||||
_seekReloading = false;
|
||||
// La nouvelle iframe repart d'une plage navigable vide, qui
|
||||
// grandira au rythme du transcodage.
|
||||
_seekableEnd = _mediaOffset;
|
||||
if (_mediaOffset > 0) _currentPosition = _mediaOffset;
|
||||
});
|
||||
|
||||
// Force unmute on load (after a short delay to ensure iframe is ready)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_sendMessage({'type': 'set_volume', 'value': 1.0});
|
||||
if (!mounted) return;
|
||||
_sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0});
|
||||
if (_rate != 1.0) {
|
||||
_sendMessage({'type': 'set_rate', 'value': _rate});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -259,28 +311,47 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
|
||||
final type = data['type'];
|
||||
if (type == 'playback_position') {
|
||||
// Un rechargement à une autre position est en cours : l'ancienne
|
||||
// iframe continue d'émettre quelques instants, ne pas la laisser
|
||||
// ramener la barre en arrière.
|
||||
if (_seekReloading) return;
|
||||
|
||||
final currentTime = (data['currentTime'] as num).toDouble();
|
||||
final rawDuration = (data['duration'] as num).toDouble();
|
||||
final duration = rawDuration.isFinite ? rawDuration : 0.0;
|
||||
final rawSeekable = data['seekableEnd'];
|
||||
final seekableEnd =
|
||||
rawSeekable is num ? rawSeekable.toDouble() : currentTime;
|
||||
|
||||
// Only update position if user is NOT dragging the slider
|
||||
if (!_isSeeking) {
|
||||
setState(() {
|
||||
setState(() {
|
||||
if (!_isSeeking) {
|
||||
_currentPosition = currentTime;
|
||||
_totalDuration = duration > 0 ? duration : 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (seekableEnd.isFinite && seekableEnd > 0) {
|
||||
_seekableEnd = seekableEnd;
|
||||
}
|
||||
});
|
||||
|
||||
// Update watch history if relevant. Jamais en live : la « position »
|
||||
// d'un flux continu n'a pas de sens et polluait le stockage avec une
|
||||
// entrée bidon par chaîne zappée.
|
||||
if (currentTime > 0 && widget.streamType != StreamType.live) {
|
||||
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||
widget.streamId,
|
||||
_positionKey,
|
||||
currentTime,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
} else if (type == 'seek_out_of_range') {
|
||||
// Cible hors de la zone déjà transcodée : seul un redémarrage du
|
||||
// flux à cet instant peut la servir.
|
||||
final target = (data['value'] as num?)?.toDouble();
|
||||
if (target != null) _restartAt(target);
|
||||
} else if (type == 'playback_ended') {
|
||||
setState(() => _isPlaying = false);
|
||||
ref.read(playbackPositionsProvider.notifier).clearPosition(_positionKey);
|
||||
} else if (type == 'playback_status') {
|
||||
if (!_ignoreStatusUpdates) {
|
||||
setState(() => _isPlaying = data['status'] == 'playing');
|
||||
@@ -346,6 +417,48 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche à un instant absolu du contenu.
|
||||
///
|
||||
/// Le lecteur tranche lui-même : saut local s'il a la donnée, sinon il
|
||||
/// répond `seek_out_of_range` et le flux est relancé à cet instant.
|
||||
void _seekTo(double target) {
|
||||
final clamped = target.clamp(0.0, _totalDuration);
|
||||
setState(() => _currentPosition = clamped);
|
||||
_sendMessage({'type': 'seek', 'value': clamped});
|
||||
_onHover();
|
||||
}
|
||||
|
||||
void _seekBy(double delta) => _seekTo(_currentPosition + delta);
|
||||
|
||||
/// Relance le flux à [target] : nouvelle session de transcodage côté
|
||||
/// serveur, donc quelques secondes d'attente — réservé aux sauts que la
|
||||
/// zone déjà transcodée ne couvre pas.
|
||||
void _restartAt(double target) {
|
||||
if (!_isSeekable || _seekReloading) return;
|
||||
// Jamais tout à la fin : FFmpeg lancé au-delà de la dernière image
|
||||
// n'encoderait rien et le lecteur n'aurait qu'une erreur à afficher.
|
||||
final ceiling =
|
||||
_totalDuration > 10 ? _totalDuration - 5 : _totalDuration;
|
||||
final clamped = target.clamp(0.0, ceiling);
|
||||
if (widget.streamType != StreamType.recording) {
|
||||
// VOD/séries : le serveur ne sait pas démarrer ailleurs qu'au début,
|
||||
// on se contente du bord de la zone disponible.
|
||||
_sendMessage({'type': 'seek', 'value': _seekableEnd});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_seekReloading = true;
|
||||
_currentPosition = clamped;
|
||||
});
|
||||
_initializePlayer(startTimeOverride: clamped, isChannelSwitch: true);
|
||||
}
|
||||
|
||||
void _setRate(double rate) {
|
||||
setState(() => _rate = rate);
|
||||
_sendMessage({'type': 'set_rate', 'value': rate});
|
||||
_onHover();
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
try {
|
||||
if (_documentIsFullscreen) {
|
||||
@@ -380,6 +493,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// current position, live restarts at the live edge.
|
||||
final resumeAt =
|
||||
widget.streamType == StreamType.live ? null : _currentPosition;
|
||||
// Un enregistrement redémarre à cette position côté serveur : sa zone
|
||||
// navigable repart de zéro.
|
||||
_initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true);
|
||||
}
|
||||
|
||||
@@ -409,17 +524,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauts proposés par les raccourcis et les boutons.
|
||||
static const _smallStep = 10.0;
|
||||
static const _bigStep = 60.0;
|
||||
|
||||
/// Vitesses de lecture proposées hors direct.
|
||||
static const _rates = <double>[0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
/// Keyboard / TV remote controls:
|
||||
/// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live),
|
||||
/// ↑/↓ = zap (live), M = mute, Esc = exit player.
|
||||
/// space-enter-K = play/pause, ←/→ = seek ±10 s (VOD) or zap (live),
|
||||
/// Maj+←/→ ou J/L = ±60 s, ↑/↓ = zap (live), 0-9 = saut en % du contenu,
|
||||
/// M = mute, F = plein écran, +/- = vitesse, Esc = exit player.
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final isLive = widget.streamType == StreamType.live;
|
||||
final key = event.logicalKey;
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
|
||||
if (key == LogicalKeyboardKey.space ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.keyK ||
|
||||
key == LogicalKeyboardKey.mediaPlayPause) {
|
||||
_togglePlayPause();
|
||||
_onHover();
|
||||
@@ -434,28 +559,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyF) {
|
||||
_toggleFullscreen();
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (isLive) {
|
||||
_previousChannel();
|
||||
_onHover();
|
||||
} else {
|
||||
_sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition - 10).clamp(0, _totalDuration),
|
||||
});
|
||||
_seekBy(shift ? -_bigStep : -_smallStep);
|
||||
}
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (isLive) {
|
||||
_nextChannel();
|
||||
_onHover();
|
||||
} else {
|
||||
_sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition + 10).clamp(0, _totalDuration),
|
||||
});
|
||||
_seekBy(shift ? _bigStep : _smallStep);
|
||||
}
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (isLive && key == LogicalKeyboardKey.arrowUp) {
|
||||
@@ -468,9 +592,68 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (!isLive) {
|
||||
if (key == LogicalKeyboardKey.keyJ) {
|
||||
_seekBy(-_bigStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyL) {
|
||||
_seekBy(_bigStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// 0-9 : saut direct au dixième correspondant du contenu.
|
||||
final decile = _decileFor(key);
|
||||
if (decile != null) {
|
||||
_seekTo(_totalDuration * decile / 10);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.period) {
|
||||
_stepRate(1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.comma) {
|
||||
_stepRate(-1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Touche 0-9 (rangée du haut ou pavé numérique) → dixième visé.
|
||||
/// Table non `const` : `LogicalKeyboardKey` redéfinit `==`, ce qui interdit
|
||||
/// une table constante.
|
||||
static final Map<LogicalKeyboardKey, int> _digitKeys = {
|
||||
LogicalKeyboardKey.digit0: 0,
|
||||
LogicalKeyboardKey.digit1: 1,
|
||||
LogicalKeyboardKey.digit2: 2,
|
||||
LogicalKeyboardKey.digit3: 3,
|
||||
LogicalKeyboardKey.digit4: 4,
|
||||
LogicalKeyboardKey.digit5: 5,
|
||||
LogicalKeyboardKey.digit6: 6,
|
||||
LogicalKeyboardKey.digit7: 7,
|
||||
LogicalKeyboardKey.digit8: 8,
|
||||
LogicalKeyboardKey.digit9: 9,
|
||||
LogicalKeyboardKey.numpad0: 0,
|
||||
LogicalKeyboardKey.numpad1: 1,
|
||||
LogicalKeyboardKey.numpad2: 2,
|
||||
LogicalKeyboardKey.numpad3: 3,
|
||||
LogicalKeyboardKey.numpad4: 4,
|
||||
LogicalKeyboardKey.numpad5: 5,
|
||||
LogicalKeyboardKey.numpad6: 6,
|
||||
LogicalKeyboardKey.numpad7: 7,
|
||||
LogicalKeyboardKey.numpad8: 8,
|
||||
LogicalKeyboardKey.numpad9: 9,
|
||||
};
|
||||
|
||||
int? _decileFor(LogicalKeyboardKey key) => _digitKeys[key];
|
||||
|
||||
void _stepRate(int direction) {
|
||||
final index = _rates.indexOf(_rate);
|
||||
final next = (index < 0 ? _rates.indexOf(1.0) : index) + direction;
|
||||
if (next < 0 || next >= _rates.length) return;
|
||||
_setRate(_rates[next]);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final hours = twoDigits(d.inHours);
|
||||
@@ -749,9 +932,28 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
|
||||
// Center Loading Indicator (only show when loading)
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
// Un saut hors de la zone transcodée relance
|
||||
// l'encodeur : quelques secondes d'attente,
|
||||
// autant dire pourquoi.
|
||||
if (_seekReloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Reprise à '
|
||||
'${_formatDuration(Duration(seconds: _currentPosition.toInt()))}…',
|
||||
style: GoogleFonts.karla(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -788,6 +990,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
activeTrackColor: AppColors.primary,
|
||||
inactiveTrackColor:
|
||||
AppColors.onSurface.withOpacity(0.2),
|
||||
// Portion déjà transcodée : navigable
|
||||
// instantanément, contrairement au
|
||||
// reste qui redémarre l'encodeur.
|
||||
secondaryActiveTrackColor:
|
||||
AppColors.onSurface.withOpacity(0.4),
|
||||
thumbColor: AppColors.onSurface,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
@@ -796,7 +1003,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
AppColors.primary.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: _currentPosition,
|
||||
value: _currentPosition
|
||||
.clamp(0.0, _totalDuration),
|
||||
secondaryTrackValue: _seekableEnd > 0
|
||||
? _seekableEnd
|
||||
.clamp(0.0, _totalDuration)
|
||||
: null,
|
||||
min: 0,
|
||||
max: _totalDuration,
|
||||
onChanged: (val) => setState(
|
||||
@@ -805,9 +1017,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
onChangeStart: (_) =>
|
||||
setState(() => _isSeeking = true),
|
||||
onChangeEnd: (val) {
|
||||
_sendMessage(
|
||||
{'type': 'seek', 'value': val},
|
||||
);
|
||||
_seekTo(val);
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
@@ -837,68 +1047,91 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.replay_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition - 10)
|
||||
.clamp(0, _totalDuration),
|
||||
}),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 10 s',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: _isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onTap: _togglePlayPause,
|
||||
size: 56,
|
||||
iconSize: 32,
|
||||
tooltip: _isPlaying ? 'Pause' : 'Lecture',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.forward_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition + 10)
|
||||
.clamp(0, _totalDuration),
|
||||
}),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 10 s',
|
||||
),
|
||||
const Spacer(),
|
||||
QualitySelectorButton(
|
||||
current: _quality,
|
||||
onSelected: _changeQuality,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
onTap: _toggleMute,
|
||||
transparent: true,
|
||||
tooltip: _isMuted
|
||||
? 'Activer le son'
|
||||
: 'Couper le son',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
onTap: _toggleFullscreen,
|
||||
transparent: true,
|
||||
tooltip: _isFullscreen
|
||||
? 'Quitter le plein écran'
|
||||
: 'Plein écran',
|
||||
),
|
||||
],
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Sous 720 px, les sauts de 60 s et le
|
||||
// sélecteur de vitesse sortent de la barre
|
||||
// plutôt que de la faire déborder.
|
||||
final compact = constraints.maxWidth < 720;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!compact) ...[
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.fast_rewind_rounded,
|
||||
onTap: () => _seekBy(-_bigStep),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 1 min',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.replay_10_rounded,
|
||||
onTap: () => _seekBy(-_smallStep),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 10 s',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: _isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onTap: _togglePlayPause,
|
||||
size: 56,
|
||||
iconSize: 32,
|
||||
tooltip:
|
||||
_isPlaying ? 'Pause' : 'Lecture',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.forward_10_rounded,
|
||||
onTap: () => _seekBy(_smallStep),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 10 s',
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.fast_forward_rounded,
|
||||
onTap: () => _seekBy(_bigStep),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 1 min',
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (!compact) ...[
|
||||
_buildRateSelector(),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
QualitySelectorButton(
|
||||
current: _quality,
|
||||
onSelected: _changeQuality,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
onTap: _toggleMute,
|
||||
transparent: true,
|
||||
tooltip: _isMuted
|
||||
? 'Activer le son'
|
||||
: 'Couper le son',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
onTap: _toggleFullscreen,
|
||||
transparent: true,
|
||||
tooltip: _isFullscreen
|
||||
? 'Quitter le plein écran'
|
||||
: 'Plein écran',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -915,6 +1148,73 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sélecteur de vitesse de lecture — utile pour parcourir un
|
||||
/// enregistrement long sans sauter à l'aveugle.
|
||||
Widget _buildRateSelector() {
|
||||
return Tooltip(
|
||||
message: 'Vitesse de lecture',
|
||||
child: PopupMenuButton<double>(
|
||||
initialValue: _rate,
|
||||
onSelected: _setRate,
|
||||
color: AppColors.surfaceContainer,
|
||||
tooltip: '',
|
||||
itemBuilder: (context) => [
|
||||
for (final rate in _rates)
|
||||
PopupMenuItem<double>(
|
||||
value: rate,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
rate == _rate
|
||||
? Icons.check_rounded
|
||||
: Icons.speed_rounded,
|
||||
size: 16,
|
||||
color: rate == _rate
|
||||
? AppColors.primary
|
||||
: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatRate(rate),
|
||||
style: TextStyle(
|
||||
color: rate == _rate
|
||||
? AppColors.primary
|
||||
: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.onSurface.withOpacity(0.15)),
|
||||
),
|
||||
child: Text(
|
||||
_formatRate(_rate),
|
||||
style: GoogleFonts.karla(
|
||||
color: _rate == 1.0 ? AppColors.onSurface : AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// « 1× », « 1.5× » — sans décimale inutile.
|
||||
static String _formatRate(double rate) {
|
||||
final text = rate == rate.roundToDouble()
|
||||
? rate.toStringAsFixed(0)
|
||||
: rate.toString();
|
||||
return '$text×';
|
||||
}
|
||||
|
||||
Widget _buildGlassIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onTap,
|
||||
|
||||
@@ -176,8 +176,11 @@ class XtreamService {
|
||||
/// Faster zapping on compatible players (mpegts.js)
|
||||
String getLiveStreamUrlTs(String streamId) {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
// Backend route injects the Xtream credentials server-side
|
||||
return '$_backendBaseUrl/api/live/$streamId.ts';
|
||||
// Route « turbo » : vidéo copiée, audio réencodé en AAC côté serveur.
|
||||
// Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio
|
||||
// d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes
|
||||
// muettes. Il reste utilisé tel quel par le scheduler d'enregistrement.
|
||||
return '$_backendBaseUrl/api/live/$streamId/turbo.ts';
|
||||
}
|
||||
|
||||
/// Generate stream URL for VOD (movies)
|
||||
@@ -188,7 +191,16 @@ class XtreamService {
|
||||
String quality = 'high',
|
||||
}) {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8';
|
||||
// `ext` : le serveur demande le fichier au panneau sous son extension
|
||||
// réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551).
|
||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'
|
||||
'${_extQuery(containerExtension, first: true)}';
|
||||
}
|
||||
|
||||
String _extQuery(String ext, {required bool first}) {
|
||||
final value = ext.trim().toLowerCase();
|
||||
if (value.isEmpty) return '';
|
||||
return '${first ? '?' : '&'}ext=${Uri.encodeQueryComponent(value)}';
|
||||
}
|
||||
|
||||
/// Generate stream URL for series episodes
|
||||
@@ -198,7 +210,8 @@ class XtreamService {
|
||||
String quality = 'high',
|
||||
}) {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series';
|
||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series'
|
||||
'${_extQuery(containerExtension, first: false)}';
|
||||
}
|
||||
|
||||
/// Authenticate and get server info
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../providers/xtream_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/favorites_provider.dart';
|
||||
import '../screens/player_screen.dart';
|
||||
import '../../../core/api/channel_logo.dart';
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
@@ -412,25 +413,26 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: channel.streamIcon.isNotEmpty
|
||||
? Image.network(
|
||||
_getProxiedIconUrl(channel.streamIcon)!,
|
||||
cacheWidth: 160,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.tv,
|
||||
color: AppColors.onSurface.withOpacity(0.3),
|
||||
size: 40,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
channel.name.characters.first.toUpperCase(),
|
||||
style: GoogleFonts.fraunces(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onSurface24,
|
||||
),
|
||||
),
|
||||
// Toujours demandé au backend, même sans
|
||||
// stream_icon : il cherche alors un logo par nom.
|
||||
child: Image.network(
|
||||
channelLogoUrl(
|
||||
streamIcon: channel.streamIcon,
|
||||
name: channel.name,
|
||||
),
|
||||
cacheWidth: 160,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, __, ___) => Text(
|
||||
channel.name.isEmpty
|
||||
? '?'
|
||||
: channel.name.characters.first.toUpperCase(),
|
||||
style: GoogleFonts.fraunces(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onSurface24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -599,14 +601,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
);
|
||||
}
|
||||
|
||||
String? _getProxiedIconUrl(String originalUrl) {
|
||||
if (originalUrl.isEmpty) return null;
|
||||
if (originalUrl.startsWith('http://')) {
|
||||
return '/api/xtream/$originalUrl';
|
||||
}
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
void _playChannel(Channel channel, List<Channel> contextList) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
||||
@@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/api/authed_http.dart';
|
||||
import '../../../core/api/recording_requests.dart';
|
||||
import '../../../core/api/channel_logo.dart';
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../providers/playback_positions_provider.dart';
|
||||
import '../providers/recordings_refresh.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
@@ -367,25 +369,25 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ch.streamIcon.isNotEmpty
|
||||
? Image.network(
|
||||
ch.streamIcon,
|
||||
width: 24,
|
||||
height: 16,
|
||||
cacheWidth: 64,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(
|
||||
Icons.tv,
|
||||
color: AppColors.onSurface24,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.tv,
|
||||
color: AppColors.onSurface24,
|
||||
size: 16,
|
||||
),
|
||||
// URL panneau brute auparavant : en
|
||||
// http depuis une page https, le
|
||||
// navigateur la bloquait.
|
||||
Image.network(
|
||||
channelLogoUrl(
|
||||
streamIcon: ch.streamIcon,
|
||||
name: ch.name,
|
||||
),
|
||||
width: 24,
|
||||
height: 16,
|
||||
cacheWidth: 64,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(
|
||||
Icons.tv,
|
||||
color: AppColors.onSurface24,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -780,14 +782,15 @@ class _ProgrammeCard extends StatelessWidget {
|
||||
// ONGLET 2 — ENREGISTREMENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _RecordingsListView extends StatefulWidget {
|
||||
class _RecordingsListView extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
const _RecordingsListView({required this.playlist});
|
||||
@override
|
||||
State<_RecordingsListView> createState() => _RecordingsListViewState();
|
||||
ConsumerState<_RecordingsListView> createState() =>
|
||||
_RecordingsListViewState();
|
||||
}
|
||||
|
||||
class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
class _RecordingsListViewState extends ConsumerState<_RecordingsListView> {
|
||||
List<dynamic> _recordings = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
@@ -917,6 +920,35 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
_fetchRecordings();
|
||||
}
|
||||
|
||||
/// Clé de reprise d'un enregistrement (même convention que le lecteur).
|
||||
static String _resumeKey(String recordingId) => 'rec_$recordingId';
|
||||
|
||||
/// Position sauvegardée, ou 0 s'il n'y en a pas d'exploitable.
|
||||
double _savedPosition(Map<String, dynamic> rec) {
|
||||
final id = rec['id'] as String?;
|
||||
if (id == null || id.isEmpty) return 0;
|
||||
return ref.read(playbackPositionsProvider).getPosition(_resumeKey(id));
|
||||
}
|
||||
|
||||
/// Durée du média : celle mesurée par le serveur si disponible, sinon la
|
||||
/// durée programmée. Un enregistrement arrêté en avance est plus court que
|
||||
/// `end_time - start_time`.
|
||||
Duration? _recordingDuration(Map<String, dynamic> rec) {
|
||||
final probed = rec['duration_seconds'];
|
||||
if (probed is num && probed > 0) {
|
||||
return Duration(seconds: probed.round());
|
||||
}
|
||||
try {
|
||||
if (rec['start_time'] != null && rec['end_time'] != null) {
|
||||
final start = DateTime.parse(rec['start_time'].toString());
|
||||
final end = DateTime.parse(rec['end_time'].toString());
|
||||
final scheduled = end.difference(start);
|
||||
if (scheduled > Duration.zero) return scheduled;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
|
||||
final recordingId = rec['id'] as String?;
|
||||
final title = rec['title'] as String? ?? 'Enregistrement';
|
||||
@@ -928,17 +960,52 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
return;
|
||||
}
|
||||
|
||||
Duration? recordingDuration;
|
||||
try {
|
||||
if (rec['start_time'] != null && rec['end_time'] != null) {
|
||||
final start = DateTime.parse(rec['start_time'].toString());
|
||||
final end = DateTime.parse(rec['end_time'].toString());
|
||||
recordingDuration = end.difference(start);
|
||||
final recordingDuration = _recordingDuration(rec);
|
||||
final saved = _savedPosition(rec);
|
||||
|
||||
// Reprise proposée, jamais imposée : un enregistrement déjà vu se
|
||||
// relance parfois depuis le début.
|
||||
double? startAt;
|
||||
if (saved > 30) {
|
||||
if (!mounted) return;
|
||||
final resume = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
title: Text(
|
||||
'Reprendre la lecture ?',
|
||||
style: GoogleFonts.fraunces(color: AppColors.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'« $title » a été interrompu à '
|
||||
'${PlaybackPositionsNotifier.formatTime(saved)}.',
|
||||
style: const TextStyle(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Depuis le début'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: const Text('Reprendre'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (resume == null) return; // dialogue fermé : on n'ouvre rien
|
||||
if (resume) {
|
||||
startAt = saved;
|
||||
} else {
|
||||
ref
|
||||
.read(playbackPositionsProvider.notifier)
|
||||
.clearPosition(_resumeKey(recordingId));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => PlayerScreen(
|
||||
streamId: recordingId,
|
||||
@@ -947,9 +1014,51 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
streamType: StreamType.recording,
|
||||
containerExtension: 'ts',
|
||||
duration: recordingDuration,
|
||||
startTime: startAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Au retour du lecteur, la progression affichée dans la liste doit
|
||||
// refléter la position atteinte.
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Barre « déjà vu jusqu'ici » sous un enregistrement entamé.
|
||||
List<Widget> _buildResumeIndicator(Map<String, dynamic> rec) {
|
||||
final saved = _savedPosition(rec);
|
||||
if (saved <= 30) return const [];
|
||||
|
||||
final total = _recordingDuration(rec)?.inSeconds ?? 0;
|
||||
final ratio = total > 0 ? (saved / total).clamp(0.0, 1.0) : null;
|
||||
|
||||
return [
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (ratio != null)
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 4,
|
||||
backgroundColor: AppColors.onSurface.withOpacity(0.1),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ratio != null) const SizedBox(width: 8),
|
||||
Text(
|
||||
'Reprendre à ${PlaybackPositionsNotifier.formatTime(saved)}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showLogs(String id, String title) async {
|
||||
@@ -1037,6 +1146,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Les positions de reprise arrivent de façon asynchrone (SharedPreferences)
|
||||
// et changent au retour du lecteur : sans cette écoute, la mention
|
||||
// « Reprendre à … » n'apparaîtrait qu'au prochain rafraîchissement.
|
||||
ref.watch(playbackPositionsProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -1178,6 +1291,8 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (status == 'completed')
|
||||
..._buildResumeIndicator(rec),
|
||||
if (rec['error_reason'] != null)
|
||||
Text(
|
||||
'⚠ ${rec['error_reason']}',
|
||||
@@ -1228,13 +1343,18 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
),
|
||||
if (status == 'completed')
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.play_circle_outline,
|
||||
icon: Icon(
|
||||
_savedPosition(rec) > 30
|
||||
? Icons.play_circle_fill
|
||||
: Icons.play_circle_outline,
|
||||
color: AppColors.success,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Lecture',
|
||||
onPressed: () => _playRecording(context, rec),
|
||||
tooltip: _savedPosition(rec) > 30
|
||||
? 'Reprendre'
|
||||
: 'Lecture',
|
||||
onPressed: () =>
|
||||
_playRecording(context, rec),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../features/iptv/providers/xtream_provider.dart';
|
||||
import '../../../../features/iptv/providers/settings_provider.dart';
|
||||
import '../../../../features/iptv/providers/favorites_provider.dart';
|
||||
import '../screens/mobile_player_screen.dart';
|
||||
import '../../../../core/api/channel_logo.dart';
|
||||
import '../../../../core/models/iptv_models.dart';
|
||||
import '../../../../core/models/playlist_config.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
@@ -349,10 +350,10 @@ class _MobileChannelTile extends ConsumerWidget {
|
||||
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
|
||||
// de l'en-tête affichait toujours une liste vide.
|
||||
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
|
||||
final iconUrl =
|
||||
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
||||
? '/api/xtream/${channel.streamIcon}'
|
||||
: null;
|
||||
final iconUrl = channelLogoUrl(
|
||||
streamIcon: channel.streamIcon,
|
||||
name: channel.name,
|
||||
);
|
||||
|
||||
return GlassContainer.glass(
|
||||
borderRadius: 12,
|
||||
@@ -372,16 +373,14 @@ class _MobileChannelTile extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: iconUrl != null
|
||||
? Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: 120,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.tv, color: AppColors.onSurface24),
|
||||
)
|
||||
: const Icon(Icons.tv, color: AppColors.onSurface24),
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: 120,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.tv, color: AppColors.onSurface24),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
|
||||
+1266
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -4,7 +4,7 @@ publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
sdk: ^3.13.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xtremflow/core/api/channel_logo.dart';
|
||||
|
||||
void main() {
|
||||
group('channelLogoUrl', () {
|
||||
test('transmet l’URL panneau et le nom, encodés', () {
|
||||
final url = Uri.parse(
|
||||
channelLogoUrl(
|
||||
streamIcon: 'http://picons.example/logos/1.png?x=1&y=2',
|
||||
name: 'FR - CANAL+ SPORT',
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.path, '/api/logo');
|
||||
expect(
|
||||
url.queryParameters['src'],
|
||||
'http://picons.example/logos/1.png?x=1&y=2',
|
||||
);
|
||||
// Le « + » doit survivre à l'encodage : sinon Canal+ devient « Canal ».
|
||||
expect(url.queryParameters['name'], 'FR - CANAL+ SPORT');
|
||||
});
|
||||
|
||||
test('envoie le nom seul sans stream_icon exploitable', () {
|
||||
// Sans URL panneau, le nom reste le seul moyen de trouver un logo.
|
||||
for (final icon in ['', ' ', 'logo.png']) {
|
||||
final url = Uri.parse(channelLogoUrl(streamIcon: icon, name: 'TF1'));
|
||||
expect(url.queryParameters.containsKey('src'), isFalse);
|
||||
expect(url.queryParameters['name'], 'TF1');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Tests du moteur de lecture web (web/xf-player-core.js).
|
||||
//
|
||||
// Lancement : node --test "test/web/*_test.mjs"
|
||||
//
|
||||
// Le moteur est un script navigateur (IIFE sur `window`) : on l'exécute dans
|
||||
// un contexte vm avec un DOM minimal et un faux mpegts.js qui capture la
|
||||
// configuration reçue.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('../../web/xf-player-core.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function fakeEventTarget() {
|
||||
return { addEventListener() {}, removeEventListener() {} };
|
||||
}
|
||||
|
||||
/** Charge le moteur et renvoie la config passée à mpegts.createPlayer. */
|
||||
function liveMpegtsConfig({ profile } = {}) {
|
||||
const created = [];
|
||||
const video = {
|
||||
...fakeEventTarget(),
|
||||
canPlayType: () => '',
|
||||
play: () => Promise.resolve(),
|
||||
buffered: { length: 0 },
|
||||
seekable: { length: 0 },
|
||||
};
|
||||
const window = {
|
||||
...fakeEventTarget(),
|
||||
console: { log() {} },
|
||||
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
||||
parent: { postMessage() {} },
|
||||
mpegts: {
|
||||
isSupported: () => true,
|
||||
Events: { MEDIA_INFO: 'media_info', ERROR: 'error' },
|
||||
createPlayer(mediaDataSource, config) {
|
||||
created.push({ mediaDataSource, config });
|
||||
return { on() {}, attachMediaElement() {}, load() {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
location: window.location,
|
||||
document: fakeEventTarget(),
|
||||
URLSearchParams,
|
||||
setInterval: () => 0,
|
||||
clearInterval() {},
|
||||
setTimeout,
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
|
||||
const XFPlayer = window.XFPlayer;
|
||||
const player = new XFPlayer({
|
||||
video,
|
||||
url: '/api/live/1/turbo.ts',
|
||||
type: 'live',
|
||||
});
|
||||
if (profile) player.profile = window.XFPlayerProfiles[profile];
|
||||
player._createMpegts(true);
|
||||
assert.equal(created.length, 1);
|
||||
return created[0].config;
|
||||
}
|
||||
|
||||
// Les panneaux Xtream relaient souvent une source HLS : le flux arrive par
|
||||
// rafales d'environ 6 s séparées de silences complets (mesuré en prod).
|
||||
const UPSTREAM_BURST_GAP_S = 7;
|
||||
|
||||
for (const profile of ['fast', 'balanced', 'safe']) {
|
||||
test(`live (${profile}) : la marge gardée après rattrapage couvre un silence amont`, () => {
|
||||
const c = liveMpegtsConfig({ profile });
|
||||
if (!c.liveBufferLatencyChasing) return; // pas de saut = rien à vérifier
|
||||
assert.ok(
|
||||
c.liveBufferLatencyMinRemain >= UPSTREAM_BURST_GAP_S,
|
||||
`MinRemain ${c.liveBufferLatencyMinRemain}s < silence amont ${UPSTREAM_BURST_GAP_S}s : ` +
|
||||
'chaque rafale déclenche un saut puis une coupure',
|
||||
);
|
||||
});
|
||||
|
||||
test(`live (${profile}) : une rafale normale ne déclenche pas de saut`, () => {
|
||||
const c = liveMpegtsConfig({ profile });
|
||||
if (!c.liveBufferLatencyChasing) return;
|
||||
// Après une rafale, le buffer vaut la marge + une rafale entière.
|
||||
assert.ok(
|
||||
c.liveBufferLatencyMaxLatency >=
|
||||
c.liveBufferLatencyMinRemain + 2 * UPSTREAM_BURST_GAP_S,
|
||||
'seuil de rattrapage trop proche de la marge : sauts à chaque rafale',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts', () => {
|
||||
const c = liveMpegtsConfig();
|
||||
assert.equal(c.liveSync, true);
|
||||
assert.ok(c.liveSyncPlaybackRate > 1 && c.liveSyncPlaybackRate <= 1.2);
|
||||
assert.ok(c.liveSyncTargetLatency < c.liveSyncMaxLatency);
|
||||
assert.ok(c.liveSyncMaxLatency <= c.liveBufferLatencyMaxLatency);
|
||||
});
|
||||
|
||||
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
|
||||
const c = liveMpegtsConfig();
|
||||
assert.equal(c.autoCleanupSourceBuffer, true);
|
||||
assert.ok(
|
||||
c.autoCleanupMinBackwardDuration < c.autoCleanupMaxBackwardDuration,
|
||||
);
|
||||
});
|
||||
+77
-24
@@ -11,7 +11,35 @@
|
||||
et environ 200 Ko de JS jamais téléchargés dans le cas courant.
|
||||
-->
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -68,35 +96,60 @@
|
||||
playOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: showError,
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
// Un enregistrement ou un film démarre une session FFmpeg côté
|
||||
// serveur : le premier essai peut tomber pendant que l'encodeur
|
||||
// se met en route. Le player mobile réessayait déjà seul ; ici
|
||||
// l'utilisateur devait relancer la lecture à la main.
|
||||
var retryCount = 0;
|
||||
var maxRetries = 3;
|
||||
var player = null;
|
||||
|
||||
function buildPlayer() {
|
||||
if (player) { try { player.destroy(); } catch (e) {} }
|
||||
|
||||
player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
retryCount = 0;
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: function (msg) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
showLoading(
|
||||
'Préparation du flux ' + retryCount + '/' + maxRetries
|
||||
);
|
||||
setTimeout(buildPlayer, 1500);
|
||||
} else {
|
||||
showError(msg);
|
||||
}
|
||||
},
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
}
|
||||
|
||||
playOverlay.addEventListener('click', function () {
|
||||
video.play();
|
||||
playOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
buildPlayer();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+73
-22
@@ -5,7 +5,35 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>XtremFlow Player Lite</title>
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -62,25 +90,49 @@
|
||||
playOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[LitePlayer]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: showError,
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
// Un serveur chargé peut rater le démarrage du transcodeur : on
|
||||
// relance un lecteur neuf avant d'afficher l'erreur, comme le
|
||||
// font déjà player.html et player_mobile.html.
|
||||
var retryCount = 0;
|
||||
var maxRetries = 3;
|
||||
var player = null;
|
||||
|
||||
function buildPlayer() {
|
||||
if (player) { try { player.destroy(); } catch (e) {} }
|
||||
|
||||
player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[LitePlayer]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
retryCount = 0;
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: function (msg) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
showLoading(
|
||||
'Reconnexion ' + retryCount + '/' + maxRetries
|
||||
);
|
||||
setTimeout(buildPlayer, 1500);
|
||||
} else {
|
||||
showError(msg);
|
||||
}
|
||||
},
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
window.xfPlayer = player;
|
||||
player.start();
|
||||
}
|
||||
|
||||
playOverlay.addEventListener('click', function () {
|
||||
video.play();
|
||||
@@ -95,8 +147,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
buildPlayer();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+29
-1
@@ -8,7 +8,35 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>XtremFlow Player Mobile</title>
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Vendored
+3
-1
@@ -1,2 +1,4 @@
|
||||
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,f(i.key),i)}}function i(e,t,i){return t&&r(e.prototype,t),i&&r(e,i),Object.defineProperty(e,"prototype",{writable:!1}),e}function n(e,t,r){return(t=f(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function a(){return a=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},a.apply(null,arguments)}function s(e){return s=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},s(e)}function o(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,h(e,t)}function l(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(l=function(){return!!e})()}function u(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function d(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?u(Object(r),!0).forEach((function(t){n(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):u(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function h(e,t){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},h(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function c(e){var t="function"==typeof Map?new Map:void 0;return c=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(l())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&h(n,r.prototype),n}(e,arguments,s(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,e)},c(e)}function g(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var v,m,p={exports:{}},y=(v||(v=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;return s},o.prototype.listenerCount=function(e){var t=r?r+e:e,i=this._events[t];return i?i.fn?1:i.length:0},o.prototype.emit=function(e,t,i,n,a,s){var o=r?r+e:e;if(!this._events[o])return!1;var l,u,d=this._events[o],h=arguments.length;if(d.fn){switch(d.once&&this.removeListener(e,d.fn,void 0,!0),h){case 1:return d.fn.call(d.context),!0;case 2:return d.fn.call(d.context,t),!0;case 3:return d.fn.call(d.context,t,i),!0;case 4:return d.fn.call(d.context,t,i,n),!0;case 5:return d.fn.call(d.context,t,i,n,a),!0;case 6:return d.fn.call(d.context,t,i,n,a,s),!0}for(u=1,l=new Array(h-1);u<h;u++)l[u-1]=arguments[u];d.fn.apply(d.context,l)}else{var f,c=d.length;for(u=0;u<c;u++)switch(d[u].once&&this.removeListener(e,d[u].fn,void 0,!0),h){case 1:d[u].fn.call(d[u].context);break;case 2:d[u].fn.call(d[u].context,t);break;case 3:d[u].fn.call(d[u].context,t,i);break;case 4:d[u].fn.call(d[u].context,t,i,n);break;default:if(!l)for(f=1,l=new Array(h-1);f<h;f++)l[f-1]=arguments[f];d[u].fn.apply(d[u].context,l)}}return!0},o.prototype.on=function(e,t,r){return a(this,e,t,r,!1)},o.prototype.once=function(e,t,r){return a(this,e,t,r,!0)},o.protLine truncated
|
||||
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,i=Array(t);r<t;r++)i[r]=e[r];return i}function i(e,t,r,i,n,a,s){try{var o=e[a](s),l=o.value}catch(e){return void r(e)}o.done?t(l):Promise.resolve(l).then(i,n)}function n(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,y(i.key),i)}}function a(e,t,r){return t&&n(e.prototype,t),r&&n(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function s(e,t){var i="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(i)return(i=i.call(e)).next.bind(i);if(Array.isArray(e)||(i=function(e,t){if(e){if("string"==typeof e)return r(e,t);var i={}.toString.call(e).slice(8,-1);return"Object"===i&&e.constructor&&(i=e.constructor.name),"Map"===i||"Set"===i?Array.from(e):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?r(e,t):void 0}}(e))||t){i&&(e=i);var n=0;return function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function o(e,t,r){return(t=y(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},l.apply(null,arguments)}function u(e){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},u(e)}function d(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,p(e,t)}function h(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(h=function(){return!!e})()}function f(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function c(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?f(Object(r),!0).forEach((function(t){o(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):f(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function g(e,t){if(null==e)return{};var r={};for(var i in e)if({}.hasOwnProperty.call(e,i)){if(-1!==t.indexOf(i))continue;r[i]=e[i]}return r}function v(){
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||
var e,t,r="function"==typeof Symbol?Symbol:{},i=r.iterator||"@@iterator",n=r.toStringTag||"@@toStringTag";function a(r,i,n,a){var l=i&&i.prototype instanceof o?i:o,u=Object.create(l.prototype);return m(u,"_invoke",function(r,i,n){var a,o,l,u=0,d=n||[],h=!1,f={p:0,n:0,v:e,a:c,f:c.bind(e,4),d:function(t,r){return a=t,o=0,l=e,f.n=r,s}};function c(r,i){for(o=r,l=i,t=0;!h&&u&&!n&&t<d.length;t++){var n,a=d[t],c=f.p,g=a[2];r>3?(n=g===i)&&(l=a[(o=a[4])?5:(o=3,3)],a[4]=a[5]=e):a[0]<=c&&((n=r<2&&c<a[1])?(o=0,f.v=i,f.n=a[1]):c<g&&(n=r<3||a[0]>i||i>g)&&(a[4]=r,a[5]=i,f.n=g,o=0))}if(n||r>1)return s;throw h=!0,i}return function(n,d,g){if(u>1)throw TypeError("Generator is already running");for(h&&1===d&&c(d,g),o=d,l=g;(t=o<2?e:l)||!h;){a||(o?o<3?(o>1&&(f.n=-1),c(o,l)):f.n=l:f.v=l);try{if(u=2,a){if(o||(n="next"),t=a[n]){if(!(t=t.call(a,l)))throw TypeError("iterator result is not an object");if(!t.done)return t;l=t.value,o<2&&(o=0)}else 1===o&&(t=a.return)&&t.call(a),o<2&&(l=TypeError("The iterator does not provide a '"+n+"' method"),o=1);a=e}else if((t=(h=f.n<0)?l:r.call(i,f))!==s)break}catch(t){a=e,o=1,l=t}finally{u=1}}return{value:t,done:h}}}(r,n,a),!0),u}var s={};function o(){}function l(){}function u(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(m(t={},i,(function(){return this})),t),h=u.prototype=o.prototype=Object.create(d);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,m(e,n,"GeneratorFunction")),e.prototype=Object.create(h),e}return l.prototype=u,m(h,"constructor",u),m(u,"constructor",l),l.displayName="GeneratorFunction",m(u,n,"GeneratorFunction"),m(h),m(h,n,"Generator"),m(h,i,(function(){return this})),m(h,"toString",(function(){return"[object Generator]"})),(v=function(){return{w:a,m:f}})()}function m(e,t,r,i){var n=Object.defineProperty;try{n({},"",{})}catch(e){n=0}m=function(e,t,r,i){function a(t,r){m(e,t,(function(e){return this._invoke(t,r,e)}))}t?n?n(e,t,{value:r,enumerable:!i,configurable:!i,writable:!i}):e[t]=r:(a("next",0),a("throw",1),a("return",2))},m(e,t,r,i)}function p(e,t){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},p(e,t)}function y(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function E(e){var t="function"==typeof Map?new Map:void 0;return E=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(h())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&p(n,r.prototype),n}(e,arguments,u(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),p(r,e)},E(e)}function T(e){return e instanceof ArrayBuffer||"undefined"!=typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer}var S="utf-16",A="utf-16be",I="utf-16le",L="utf-8";function R(e,t){if(e<0)return-R(-e,t);var r=Math.pow(10,t);if(Math.abs(e*r%1-.5)<Number.EPSILON){var i=Math.floor(e*r);return(i%2==0?i:i+1)/r}return Math.round(e*r)/r}function b(){try{return crypto.randomUUID()}catch(i){try{var e=URL.createObjectURL(new Blob),t=e.toString();return URL.revokeObjectURL(e),t.slice(t.lastIndexOf("/")+1)}catch(e){var r=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var t=(r+16*Math.random())%16|0;return r=Math.floor(r/16),("x"==e?t:3&t|8).toString(16)}))}}}function k(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var D,P,_={exports:{}},C=(D||(D=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;retLine truncated
|
||||
//# sourceMappingURL=hls.min.js.map
|
||||
Vendored
+2
-8
@@ -1,9 +1,3 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.mpegts=t():e.mpegts=t()}(window,(function(){return function(e){var t={};function i(n){if(t[n])return t[n].exports;var a=t[n]={i:n,l:!1,exports:{}};return e[n].call(a.exports,a,a.exports,i),a.l=!0,a.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)i.d(n,a,function(t){return e[t]}.bind(null,a));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="",i(i.s=14)}([function(e,t,i){"use strict";var n=i(6),a=i.n(n),r=function(){function e(){}return e.e=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","error",n),e.ENABLE_ERROR&&(console.error?console.error(n):console.warn?console.warn(n):console.log(n))},e.i=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","info",n),e.ENABLE_INFO&&(console.info?console.info(n):console.log(n))},e.w=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","warn",n),e.ENABLE_WARN&&(console.warn?console.warn(n):console.log(n))},e.d=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","debug",n),e.ENABLE_DEBUG&&(console.debug?console.debug(n):console.log(n))},e.v=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","verbose",n),e.ENABLE_VERBOSE&&console.log(n)},e}();r.GLOBAL_TAG="mpegts.js",r.FORCE_GLOBAL_TAG=!1,r.ENABLE_ERROR=!0,r.ENABLE_INFO=!0,r.ENABLE_WARN=!0,r.ENABLE_DEBUG=!0,r.ENABLE_VERBOSE=!0,r.ENABLE_CALLBACK=!1,r.emitter=new a.a,t.a=r},function(e,t,i){"use strict";t.a={IO_ERROR:"io_error",DEMUX_ERROR:"demux_error",INIT_SEGMENT:"init_segment",MEDIA_SEGMENT:"media_segment",LOADING_COMPLETE:"loading_complete",RECOVERED_EARLY_EOF:"recovered_early_eof",MEDIA_INFO:"media_info",METADATA_ARRIVED:"metadata_arrived",SCRIPTDATA_ARRIVED:"scriptdata_arrived",TIMED_ID3_METADATA_ARRIVED:"timed_id3_metadata_arrived",SMPTE2038_METADATA_ARRIVED:"smpte2038_metadata_arrived",SCTE35_METADATA_ARRIVED:"scte35_metadata_arrived",PES_PRIVATE_DATA_DESCRIPTOR:"pes_private_data_descriptor",PES_PRIVATE_DATA_ARRIVED:"pes_private_data_arrived",STATISTICS_INFO:"statistics_info",RECOMMEND_SEEKPOINT:"recommend_seekpoint"}},function(e,t,i){"use strict";i.d(t,"c",(function(){return a})),i.d(t,"b",(function(){return r})),i.d(t,"a",(function(){return s}));var n=i(3),a={kIdle:0,kConnecting:1,kBuffering:2,kError:3,kComplete:4},r={OK:"OK",EXCEPTION:"Exception",HTTP_STATUS_CODE_INVALID:"HttpStatusCodeInvalid",CONNECTING_TIMEOUT:"ConnectingTimeout",EARLY_EOF:"EarlyEof",UNRECOVERABLE_EARLY_EOF:"UnrecoverableEarlyEof"},s=function(){function e(e){this._type=e||"undefined",this._status=a.kIdle,this._needStash=!1,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null}return e.prototype.destroy=function(){this._status=a.kIdle,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null},e.prototype.isWorking=function(){return this._status===a.kConnecting||this._status===a.kBuffering},Object.defineProperty(e.prototype,"type",{get:function(){return this._type},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"status",{get:function(){return this._status},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"needStashBuffer",{get:function(){return this._needStash},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onContentLengthKnown",{get:function(){return this._onContentLengthKnown},set:function(e){this._onContentLengthKnown=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onURLRedirect",{get:function(){return this._onURLRedirect},set:function(e){this._onURLRedirect=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onDataArrival",{get:function(){return this._onDataArrival},set:function(e){this._onDataArrival=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onError",{get:function(){return this._onError},set:function(e){this._onError=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onComplete",{get:fuLine truncated
|
||||
/*!
|
||||
* @overview es6-promise - a tiny implementation of Promises/A+.
|
||||
* @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
|
||||
* @license Licensed under MIT license
|
||||
* See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
|
||||
* @version v4.2.8+1e68dce6
|
||||
*/var n;n=function(){"use strict";function e(e){return"function"==typeof e}var n=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},a=0,r=void 0,s=void 0,o=function(e,t){f[a]=e,f[a+1]=t,2===(a+=2)&&(s?s(p):b())},d="undefined"!=typeof window?window:void 0,_=d||{},h=_.MutationObserver||_.WebKitMutationObserver,c="undefined"==typeof self&&void 0!==t&&"[object process]"==={}.toString.call(t),u="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function l(){var e=setTimeout;return function(){return e(p,1)}}var f=new Array(1e3);function p(){for(var e=0;e<a;e+=2)(0,f[e])(f[e+1]),f[e]=void 0,f[e+1]=void 0;a=0}var m,g,v,y,b=void 0;function S(e,t){var i=this,n=new this.constructor(R);void 0===n[A]&&P(n);var a=i._state;if(a){var r=arguments[a-1];o((function(){return I(a,n,r,i._result)}))}else C(i,n,e,t);return n}function E(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(R);return L(t,e),t}c?b=function(){return t.nextTick(p)}:h?(g=0,v=new h(p),y=document.createTextNode(""),v.observe(y,{characterData:!0}),b=function(){y.data=g=++g%2}):u?((m=new MessageChannel).port1.onmessage=p,b=function(){return m.port2.postMessage(0)}):b=void 0===d?function(){try{var e=Function("return this")().require("vertx");return void 0!==(r=e.runOnLoop||e.runOnContext)?function(){r(p)}:l()}catch(e){return l()}}():l();var A=Math.random().toString(36).substring(2);function R(){}function T(t,i,n){i.constructor===t.constructor&&n===S&&i.constructor.resolve===E?function(e,t){1===t._state?k(e,t._result):2===t._state?D(e,t._result):C(t,void 0,(function(t){return L(e,t)}),(function(t){return D(e,t)}))}(t,i):void 0===n?k(t,i):e(n)?function(e,t,i){o((function(e){var n=!1,a=function(e,t,i,n){try{e.call(t,i,n)}catch(e){return e}}(i,t,(function(i){n||(n=!0,t!==i?L(e,i):k(e,i))}),(function(t){n||(n=!0,D(e,t))}),e._label);!n&&a&&(n=!0,D(e,a))}),e)}(t,i,n):k(t,i)}function L(e,t){if(e===t)D(e,new TypeError("You cannot resolve a promise with itself"));else if(a=typeof(n=t),null===n||"object"!==a&&"function"!==a)k(e,t);else{var i=void 0;try{i=t.then}catch(t){return void D(e,t)}T(e,t,i)}var n,a}function w(e){e._onerror&&e._onerror(e._result),B(e)}function k(e,t){void 0===e._state&&(e._result=t,e._state=1,0!==e._subscribers.length&&o(B,e))}function D(e,t){void 0===e._state&&(e._state=2,e._result=t,o(w,e))}function C(e,t,i,n){var a=e._subscribers,r=a.length;e._onerror=null,a[r]=t,a[r+1]=i,a[r+2]=n,0===r&&e._state&&o(B,e)}function B(e){var t=e._subscribers,i=e._state;if(0!==t.length){for(var n=void 0,a=void 0,r=e._result,s=0;s<t.length;s+=3)n=t[s],a=t[s+i],n?I(i,n,a,r):a(r);e._subscribers.length=0}}function I(t,i,n,a){var r=e(n),s=void 0,o=void 0,d=!0;if(r){try{s=n(a)}catch(e){d=!1,o=e}if(i===s)return void D(i,new TypeError("A promises callback cannot return that same promise."))}else s=a;void 0!==i._state||(r&&d?L(i,s):!1===d?D(i,o):1===t?k(i,s):2===t&&D(i,s))}var O=0;function P(e){e[A]=O++,e._state=void 0,e._result=void 0,e._subscribers=[]}var M=function(){function e(e,t){this._instanceConstructor=e,this.promise=new e(R),this.promise[A]||P(this.promise),n(t)?(this.length=t.length,this._remaining=t.length,this._result=new Array(this.length),0===this.length?k(this.promise,this._result):(this.length=this.length||0,this._enumerate(t),0===this._remaining&&k(this.promise,this._result))):D(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;void 0===this._state&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var i=this._instanceConstructor,n=i.resolve;if(n===E){var a=void 0,r=void 0,s=!1;try{a=e.then}catch(e){s=!0,r=e}if(a===S&&void 0!==e._state)this._settledAt(e._state,t,e._result);else if("function"!=typeof a)this._remaining--,this._result[t]=e;else if(i===x){var o=new i(R);s?D(o,r):T(o,e,a),this._willSettleAt(o,t)}else this._willSettleAt(new i((function(t){return t(e)})),t)}else this._willSettleAt(n(e),t)},e.prototype._settledAt=function(e,t,i){var n=this.promise;void 0===n._state&&(this._remaining--,2===e?D(n,i):this._result[t]=i),0===this._remaining&&k(n,this._result)},e.prototype._willSettleAt=function(e,t){var i=this;C(e,void 0,(function(e){return i._settledAt(1,t,e)}),(function(e){return i._settledAt(2,t,e)}))},e}(),x=function(){function t(e){this[A]=O++,this._result=this._state=void 0,this._subscribers=[],R!==e&&("function"!=typeof e&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof t?function(e,t){try{t((function(t){L(e,t)}),(function(t){D(e,t)}))}catch(t){D(e,t)}}(this,e):function(){throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.")}())}return t.prototype.catch=function(e){return this.then(null,e)},t.prototype.finally=function(t){var i=this.constructor;returLine truncated
|
||||
/*! For license information please see mpegts.js.LICENSE.txt */
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.mpegts=t():e.mpegts=t()}(this,function(){return function(){var e={964:function(e,t,i){e.exports=function(){"use strict";function e(e){return"function"==typeof e}var t=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},a=0,n=void 0,r=void 0,o=function(e,t){h[a]=e,h[a+1]=t,2===(a+=2)&&(r?r(f):y())};var s="undefined"!=typeof window?window:void 0,d=s||{},_=d.MutationObserver||d.WebKitMutationObserver,l="undefined"==typeof self&&"undefined"!=typeof process&&"[object process]"==={}.toString.call(process),u="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function c(){var e=setTimeout;return function(){return e(f,1)}}var h=new Array(1e3);function f(){for(var e=0;e<a;e+=2)(0,h[e])(h[e+1]),h[e]=void 0,h[e+1]=void 0;a=0}var p,m,g,v,y=void 0;function S(e,t){var i=this,a=new this.constructor(b);void 0===a[A]&&x(a);var n=i._state;if(n){var r=arguments[n-1];o(function(){return O(n,a,r,i._result)})}else I(i,a,e,t);return a}function E(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(b);return D(t,e),t}y=l?function(){return process.nextTick(f)}:_?(m=0,g=new _(f),v=document.createTextNode(""),g.observe(v,{characterData:!0}),function(){v.data=m=++m%2}):u?((p=new MessageChannel).port1.onmessage=f,function(){return p.port2.postMessage(0)}):void 0===s?function(){try{var e=Function("return this")().require("vertx");return void 0!==(n=e.runOnLoop||e.runOnContext)?function(){n(f)}:c()}catch(e){return c()}}():c();var A=Math.random().toString(36).substring(2);function b(){}var R=void 0,T=1,L=2;function k(t,i,a){i.constructor===t.constructor&&a===S&&i.constructor.resolve===E?function(e,t){t._state===T?C(e,t._result):t._state===L?w(e,t._result):I(t,void 0,function(t){return D(e,t)},function(t){return w(e,t)})}(t,i):void 0===a?C(t,i):e(a)?function(e,t,i){o(function(e){var a=!1,n=function(e,t,i,a){try{e.call(t,i,a)}catch(e){return e}}(i,t,function(i){a||(a=!0,t!==i?D(e,i):C(e,i))},function(t){a||(a=!0,w(e,t))},e._label);!a&&n&&(a=!0,w(e,n))},e)}(t,i,a):C(t,i)}function D(e,t){if(e===t)w(e,new TypeError("You cannot resolve a promise with itself"));else if(n=typeof(a=t),null===a||"object"!==n&&"function"!==n)C(e,t);else{var i=void 0;try{i=t.then}catch(t){return void w(e,t)}k(e,t,i)}var a,n}function M(e){e._onerror&&e._onerror(e._result),B(e)}function C(e,t){e._state===R&&(e._result=t,e._state=T,0!==e._subscribers.length&&o(B,e))}function w(e,t){e._state===R&&(e._state=L,e._result=t,o(M,e))}function I(e,t,i,a){var n=e._subscribers,r=n.length;e._onerror=null,n[r]=t,n[r+T]=i,n[r+L]=a,0===r&&e._state&&o(B,e)}function B(e){var t=e._subscribers,i=e._state;if(0!==t.length){for(var a=void 0,n=void 0,r=e._result,o=0;o<t.length;o+=3)a=t[o],n=t[o+i],a?O(i,a,n,r):n(r);e._subscribers.length=0}}function O(t,i,a,n){var r=e(a),o=void 0,s=void 0,d=!0;if(r){try{o=a(n)}catch(e){d=!1,s=e}if(i===o)return void w(i,new TypeError("A promises callback cannot return that same promise."))}else o=n;i._state!==R||(r&&d?D(i,o):!1===d?w(i,s):t===T?C(i,o):t===L&&w(i,o))}var P=0;function x(e){e[A]=P++,e._state=void 0,e._result=void 0,e._subscribers=[]}var U=function(){function e(e,i){this._instanceConstructor=e,this.promise=new e(b),this.promise[A]||x(this.promise),t(i)?(this.length=i.length,this._remaining=i.length,this._result=new Array(this.length),0===this.length?C(this.promise,this._result):(this.length=this.length||0,this._enumerate(i),0===this._remaining&&C(this.promise,this._result))):w(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;this._state===R&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var i=this._instanceConstructor,a=i.resolve;if(a===E){var n=void 0,r=void 0,o=!1;try{n=e.then}catch(e){o=!0,r=e}if(n===S&&e._state!==R)this._settledAt(e._state,t,e._result);else if("function"!=typeof n)this._remaining--,this._result[t]=e;else if(i===N){var s=new i(b);o?w(s,r):k(s,e,n),this._willSettleAt(s,t)}else this._willSettleAt(new i(function(t){return t(e)}),t)}else this._willSettleAt(a(e),t)},e.prototype._settledAt=function(e,t,i){var a=this.promise;a._state===R&&(this._remaining--,e===L?w(a,i):this._result[t]=i),0===this._remaining&&C(a,this._result)},e.prototype._willSettleAt=function(e,t){var i=this;I(e,void 0,function(e){return i._settledAt(T,t,e)},function(e){return i._settledAt(L,t,e)})},e}();var N=function(){function t(e){this[A]=P++,this._result=this._state=void 0,this._subscribers=[],b!==e&&("function"!=typeof e&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof t?function(e,t){try{t(function(t){D(e,t)},function(t){w(e,t)})}catch(t){w(e,t)}}(this,e):function(){throw new TypeELine truncated
|
||||
//# sourceMappingURL=mpegts.js.map
|
||||
+322
-37
@@ -55,22 +55,75 @@
|
||||
|
||||
var ORDER = ['fast', 'balanced', 'safe'];
|
||||
|
||||
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
|
||||
var MPEGTS_MAX_ERRORS = 3;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Chargement paresseux des libs (aucune requête inutile)
|
||||
// ------------------------------------------------------------------
|
||||
var _scriptCache = {};
|
||||
|
||||
function loadScript(src) {
|
||||
if (_scriptCache[src]) return _scriptCache[src];
|
||||
_scriptCache[src] = new Promise(function (resolve, reject) {
|
||||
function _delay(ms) {
|
||||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function _injectScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = function () { reject(new Error('Échec du chargement : ' + src)); };
|
||||
s.onload = function () { resolve(); };
|
||||
s.onerror = function () {
|
||||
// Laisser une balise morte dans le <head> ferait échouer une
|
||||
// éventuelle nouvelle tentative sur le même src.
|
||||
if (s.parentNode) s.parentNode.removeChild(s);
|
||||
reject(new Error('script error'));
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _scriptCache[src];
|
||||
}
|
||||
|
||||
/**
|
||||
* Précise la cause d'un échec pour le message affiché et les logs :
|
||||
* 404 = lib absente de l'image déployée, 429 = quota du rate limiter,
|
||||
* échec réseau = lien coupé. Sans cela, toutes les causes se
|
||||
* ressemblaient à l'écran.
|
||||
*/
|
||||
function _describeFailure(src) {
|
||||
if (typeof fetch !== 'function') return Promise.resolve('');
|
||||
return fetch(src, { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? '' : ' (HTTP ' + r.status + ')'; })
|
||||
.catch(function () { return ' (réseau injoignable)'; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge une lib vendorisée, avec une seconde tentative qui contourne le
|
||||
* cache HTTP : les libs sont servies en `max-age=86400`, donc une entrée
|
||||
* tronquée par une coupure réseau condamnait le lecteur jusqu'au vidage
|
||||
* manuel du cache navigateur. Seuls les succès sont mémorisés — une
|
||||
* promesse rejetée gardée en cache rendait tout réessai impossible pour
|
||||
* le reste de la session.
|
||||
*/
|
||||
function loadScript(src) {
|
||||
if (_scriptCache[src]) return _scriptCache[src];
|
||||
|
||||
var attempt = _injectScript(src)
|
||||
.catch(function () {
|
||||
return _delay(400).then(function () {
|
||||
return _injectScript(
|
||||
src + (src.indexOf('?') === -1 ? '?' : '&') + 'reload=' + Date.now()
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
return _describeFailure(src).then(function (why) {
|
||||
throw new Error('Échec du chargement : ' + src + why);
|
||||
});
|
||||
});
|
||||
|
||||
_scriptCache[src] = attempt;
|
||||
attempt.catch(function () { delete _scriptCache[src]; });
|
||||
return attempt;
|
||||
}
|
||||
|
||||
/** Ouvre la connexion vers l'hôte du flux avant même de connaître l'URL finale. */
|
||||
@@ -111,6 +164,13 @@
|
||||
this.injectedDuration = parseFloat(p.get('duration') || '0');
|
||||
this.isRecording = p.get('is_recording') === 'true';
|
||||
|
||||
// Décalage du média par rapport au début du contenu. Un enregistrement
|
||||
// ouvert à 45 min est transcodé par FFmpeg *depuis* 45 min : le média
|
||||
// commence à 0 alors que la lecture, elle, est à 2700 s. Tout ce qui
|
||||
// sort du lecteur (position, recherche) est exprimé en temps absolu.
|
||||
this.mediaOffset = parseFloat(p.get('offset') || '0');
|
||||
if (!isFinite(this.mediaOffset) || this.mediaOffset < 0) this.mediaOffset = 0;
|
||||
|
||||
// Profil de départ : forçable via ?buffer=balanced|safe
|
||||
var forced = p.get('buffer');
|
||||
this.profile = PROFILES[forced] ? PROFILES[forced] : PROFILES.fast;
|
||||
@@ -121,11 +181,23 @@
|
||||
this._started = false;
|
||||
this._stalls = 0;
|
||||
this._stallTimes = [];
|
||||
this._mpegtsErrorTimes = [];
|
||||
this._destroyed = false;
|
||||
this._reportedTime = 0;
|
||||
this._hlsDuration = 0;
|
||||
|
||||
// Écouteurs posés par cette instance, retirés à la destruction : sans
|
||||
// ça, une relance après échec laisserait l'ancienne instance réagir aux
|
||||
// commandes du parent et republier des positions périmées.
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
/** addEventListener + mémorisation pour un retrait propre. */
|
||||
XFPlayer.prototype._on = function (target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._listeners.push({ target: target, type: type, handler: handler });
|
||||
};
|
||||
|
||||
XFPlayer.prototype.log = function (m) {
|
||||
if (global.console && console.log) console.log(this.logPrefix + ' ' + m);
|
||||
};
|
||||
@@ -174,6 +246,8 @@
|
||||
|
||||
XFPlayer.prototype._hlsConfig = function () {
|
||||
var prof = this.profile.hls;
|
||||
var isLive = this.type === 'live';
|
||||
|
||||
return {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -191,15 +265,39 @@
|
||||
startFragPrefetch: true, // précharge le 1er fragment pendant le parse
|
||||
testBandwidth: false, // pas de mesure préalable : on joue tout de suite
|
||||
|
||||
// ---- Début de lecture ----
|
||||
// Un enregistrement (ou un film) est transcodé au fil de l'eau : sa
|
||||
// playlist n'a pas encore d'`EXT-X-ENDLIST`, donc hls.js la considère
|
||||
// comme du direct. Sans consigne explicite il démarre alors au « bord
|
||||
// du direct », c'est-à-dire collé au front d'encodage : la lecture
|
||||
// partait au milieu du programme et se coupait aussitôt, faute de
|
||||
// segments d'avance. `0` = au début, comme attendu d'un contenu
|
||||
// à la demande. En direct, -1 conserve le démarrage au bord du direct.
|
||||
startPosition: isLive ? -1 : (this.startTime > 0 ? this.startTime : 0),
|
||||
|
||||
// ---- Live ----
|
||||
liveSyncDurationCount: prof.liveSyncDurationCount,
|
||||
liveMaxLatencyDurationCount: prof.liveMaxLatencyDurationCount,
|
||||
maxLiveSyncPlaybackRate: 1.15,
|
||||
// Hors direct, ces deux réglages doivent être neutralisés : ils
|
||||
// accélèrent la lecture et finissent par forcer un saut en avant pour
|
||||
// « rattraper » un direct qui n'existe pas.
|
||||
// 86400 segments = hors d'atteinte, sans recourir à Infinity dont
|
||||
// hls.js fait ensuite des multiplications.
|
||||
liveMaxLatencyDurationCount: isLive
|
||||
? prof.liveMaxLatencyDurationCount
|
||||
: 86400,
|
||||
maxLiveSyncPlaybackRate: isLive ? 1.15 : 1,
|
||||
|
||||
// ---- Robustesse ----
|
||||
nudgeOffset: 0.6,
|
||||
nudgeMaxRetries: 20,
|
||||
abrEwmaDefaultEstimate: 2000000,
|
||||
// Hors direct, la première requête de playlist peut légitimement
|
||||
// bloquer le temps que FFmpeg démarre et produise ses premiers
|
||||
// segments. Le défaut de 10 s de hls.js transformait ce démarrage en
|
||||
// « erreur de chargement » — il fallait relancer la lecture pour
|
||||
// tomber sur une session déjà chaude.
|
||||
manifestLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
levelLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
manifestLoadingRetryDelay: 500,
|
||||
levelLoadingRetryDelay: 500,
|
||||
fragLoadingRetryDelay: 500,
|
||||
@@ -223,10 +321,15 @@
|
||||
self.hls = hls;
|
||||
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance
|
||||
|
||||
// Faux tant que la première playlist n'a pas été obtenue : voir le
|
||||
// traitement des erreurs réseau plus bas.
|
||||
var manifestParsed = false;
|
||||
|
||||
hls.loadSource(self.url);
|
||||
hls.attachMedia(self.video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
manifestParsed = true;
|
||||
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms.
|
||||
// Si le réseau ne suit pas, l'escalade de profil s'en chargera.
|
||||
self._attemptPlay();
|
||||
@@ -243,6 +346,15 @@
|
||||
if (!d.fatal) return;
|
||||
self.log('Erreur HLS fatale : ' + d.details);
|
||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
// Playlist jamais obtenue (transcodeur en échec → 502, serveur trop
|
||||
// chargé → délai dépassé), après les relances internes de hls.js.
|
||||
// `startLoad()` ne relance pas ce chargement-là : le lecteur
|
||||
// restait muet sur un spinner infini. On remonte l'erreur pour que
|
||||
// la page relance un lecteur neuf ou l'affiche.
|
||||
if (!manifestParsed) {
|
||||
self.onError('Flux indisponible : le serveur n\'a pas pu le préparer');
|
||||
return;
|
||||
}
|
||||
self._escalate('erreur réseau');
|
||||
hls.startLoad();
|
||||
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
@@ -253,6 +365,11 @@
|
||||
}
|
||||
});
|
||||
}).catch(function (err) {
|
||||
// hls.js indisponible : plutôt que d'échouer, tenter les chemins qui
|
||||
// ne demandent aucune lib (HLS natif Safari/iOS, sinon lecture directe
|
||||
// si le navigateur sait décoder le conteneur).
|
||||
self.log(err.message);
|
||||
if (self._canPlayNativeHls()) { self._playDirect(self.url); return; }
|
||||
self.onError(err.message);
|
||||
});
|
||||
};
|
||||
@@ -267,6 +384,24 @@
|
||||
}
|
||||
self._createMpegts(isLive);
|
||||
}).catch(function (err) {
|
||||
// mpegts.js indisponible (build incomplet, coupure réseau, quota du
|
||||
// rate limiter…) : le même flux reste lisible par la route HLS, où
|
||||
// FFmpeg réencode déjà l'audio en AAC. Une seconde de latence en plus
|
||||
// vaut mieux qu'un écran d'erreur sur une chaîne parfaitement lisible.
|
||||
var hlsUrl = self._hlsEquivalent(self.url);
|
||||
self.log(err.message + (hlsUrl ? ' — bascule sur le chemin HLS' : ''));
|
||||
if (hlsUrl) {
|
||||
self.url = hlsUrl;
|
||||
self.onLoading('Ouverture du flux');
|
||||
if (self._canPlayNativeHls()) { self._playDirect(hlsUrl); return; }
|
||||
return self._startHls();
|
||||
}
|
||||
// Aucun équivalent HLS (flux direct fourni par la playlist) : dernier
|
||||
// recours, laisser le navigateur décoder lui-même le conteneur.
|
||||
if (self.video.canPlayType('video/mp2t') !== '') {
|
||||
self._playDirect(self.url);
|
||||
return;
|
||||
}
|
||||
self.onError(err.message);
|
||||
});
|
||||
};
|
||||
@@ -284,7 +419,28 @@
|
||||
// 64 Ko en profil « fast » contre 512 Ko auparavant.
|
||||
stashInitialSize: this.profile.mpegtsStash,
|
||||
maxStashSize: 30 * 1024 * 1024,
|
||||
liveBufferLatencyChasing: false,
|
||||
// POURQUOI ces seuils : les panneaux Xtream relaient le plus souvent
|
||||
// une source HLS et livrent le flux par RAFALES (~6 s de contenu d'un
|
||||
// coup, puis plusieurs secondes de silence total — mesuré en prod).
|
||||
// L'ancien rattrapage (5 s max, 1 s de marge) sautait en avant à
|
||||
// chaque rafale puis tombait à sec pendant le silence suivant :
|
||||
// coupure toutes les 3-4 s et contenu sauté (12 sauts et 14 coupures
|
||||
// en 30 s sur une chaîne FHD). La marge de 12 s couvre deux silences ;
|
||||
// le saut ne sert plus qu'à borner un retard réellement accumulé.
|
||||
liveBufferLatencyChasing: isLive,
|
||||
liveBufferLatencyMaxLatency: 30,
|
||||
liveBufferLatencyMinRemain: 12,
|
||||
// Au-delà de 20 s de retard, accélérer légèrement (x1,1) jusqu'à
|
||||
// revenir à 12 s : rattrapage invisible, sans saut ni coupure.
|
||||
liveSync: isLive,
|
||||
liveSyncMaxLatency: 20,
|
||||
liveSyncTargetLatency: 12,
|
||||
liveSyncPlaybackRate: 1.1,
|
||||
// Purge du buffer déjà lu : sans elle, une longue séance finit par
|
||||
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
|
||||
autoCleanupSourceBuffer: true,
|
||||
autoCleanupMaxBackwardDuration: 60,
|
||||
autoCleanupMinBackwardDuration: 30,
|
||||
lazyLoad: false
|
||||
}
|
||||
);
|
||||
@@ -323,6 +479,37 @@
|
||||
player.on(mpegts.Events.ERROR, function (type, detail) {
|
||||
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
|
||||
if (self._destroyed) return;
|
||||
|
||||
// Chaque recréation relance un FFmpeg côté serveur : sur un serveur
|
||||
// déjà saturé, recréer sans fin toutes les 1,2 s ne faisait
|
||||
// qu'aggraver la charge. Au-delà de quelques échecs rapprochés, on
|
||||
// passe par la route HLS, qui renvoie un vrai code d'erreur.
|
||||
var now = Date.now();
|
||||
self._mpegtsErrorTimes = self._mpegtsErrorTimes.filter(function (t) {
|
||||
return now - t < 60000;
|
||||
});
|
||||
self._mpegtsErrorTimes.push(now);
|
||||
if (self._mpegtsErrorTimes.length > MPEGTS_MAX_ERRORS) {
|
||||
try {
|
||||
player.unload();
|
||||
player.detachMediaElement();
|
||||
player.destroy();
|
||||
} catch (e) {}
|
||||
self.mpegts = null;
|
||||
|
||||
var hlsUrl = self._hlsEquivalent(self.url);
|
||||
if (!hlsUrl) {
|
||||
self.onError('Flux indisponible');
|
||||
return;
|
||||
}
|
||||
self.log('Erreurs MPEG-TS répétées — bascule HLS');
|
||||
self.onLoading('Ouverture du flux');
|
||||
self.url = hlsUrl;
|
||||
if (self._canPlayNativeHls()) self._playDirect(hlsUrl);
|
||||
else self._startHls();
|
||||
return;
|
||||
}
|
||||
|
||||
self._escalate('erreur MPEG-TS');
|
||||
// Recréation propre avec le nouveau profil.
|
||||
setTimeout(function () {
|
||||
@@ -341,13 +528,15 @@
|
||||
this._attemptPlay();
|
||||
};
|
||||
|
||||
/// `/api/live/<id>.ts` → `/api/live/<id>/source/playlist.m3u8`.
|
||||
/// `/api/live/<id>/turbo.ts` (ou l'ancien `/api/live/<id>.ts`)
|
||||
/// → `/api/live/<id>/source/playlist.m3u8`.
|
||||
///
|
||||
/// Le préréglage `source` garde la vidéo en copie de flux (`-c:v copy`) :
|
||||
/// seul l'audio est réencodé, le coût processeur reste négligeable.
|
||||
/// Filet de sécurité : la route turbo réencode déjà l'audio en AAC côté
|
||||
/// serveur, donc ce fallback ne devrait plus jamais se déclencher — il
|
||||
/// reste pour couvrir un flux sans piste audio exploitable du tout.
|
||||
/// Renvoie `null` si l'URL n'est pas un flux live direct — rien à tenter.
|
||||
XFPlayer.prototype._hlsEquivalent = function (url) {
|
||||
var match = /^(.*\/api\/live\/)([^/?#]+)\.ts(\?.*)?$/.exec(url);
|
||||
var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url);
|
||||
if (!match) return null;
|
||||
return match[1] + match[2] + '/source/playlist.m3u8';
|
||||
};
|
||||
@@ -432,40 +621,94 @@
|
||||
var self = this;
|
||||
var v = this.video;
|
||||
|
||||
v.addEventListener('loadedmetadata', function () {
|
||||
this._on(v, 'loadedmetadata', function () {
|
||||
if (self.startTime > 0 && self.startTime < v.duration) {
|
||||
v.currentTime = self.startTime;
|
||||
}
|
||||
});
|
||||
|
||||
v.addEventListener('playing', function () {
|
||||
this._on(v, 'playing', function () {
|
||||
self._started = true;
|
||||
self.onReady();
|
||||
self.send({ type: 'playback_status', status: 'playing' });
|
||||
});
|
||||
|
||||
v.addEventListener('pause', function () {
|
||||
this._on(v, 'pause', function () {
|
||||
self.send({ type: 'playback_status', status: 'paused' });
|
||||
});
|
||||
|
||||
v.addEventListener('waiting', function () { self._noteStall(); });
|
||||
v.addEventListener('stalled', function () { self._noteStall(); });
|
||||
this._on(v, 'waiting', function () { self._noteStall(); });
|
||||
this._on(v, 'stalled', function () { self._noteStall(); });
|
||||
|
||||
v.addEventListener('ended', function () {
|
||||
this._on(v, 'ended', function () {
|
||||
self.send({
|
||||
type: 'playback_ended',
|
||||
duration: isFinite(v.duration) ? v.duration : self.injectedDuration
|
||||
duration: self._totalDuration()
|
||||
});
|
||||
});
|
||||
|
||||
// Le front de transcodage avance : le parent doit pouvoir griser la
|
||||
// portion pas encore navigable de la barre de progression. `progress`
|
||||
// se déclenche à chaque bloc reçu — bridé à 2 s, sinon chaque segment
|
||||
// provoquerait une reconstruction de l'interface Flutter.
|
||||
this._on(v, 'progress', function () {
|
||||
if (!self._started) return;
|
||||
var now = Date.now();
|
||||
if (now - (self._lastProgressSent || 0) < 2000) return;
|
||||
self._lastProgressSent = now;
|
||||
self._sendPosition();
|
||||
});
|
||||
|
||||
this._on(v, 'seeked', function () { self._sendPosition(); });
|
||||
|
||||
// Remontée de position (5 s) — même contrat qu'avant.
|
||||
this._posTimer = setInterval(function () { self._reportPosition(); }, 5000);
|
||||
|
||||
// Activité utilisateur → masquage auto des contrôles côté Flutter.
|
||||
function activity() { self.send({ type: 'user_activity' }); }
|
||||
document.addEventListener('mousemove', activity, { passive: true });
|
||||
document.addEventListener('touchstart', activity, { passive: true });
|
||||
document.addEventListener('click', activity, { passive: true });
|
||||
this._on(document, 'mousemove', activity, { passive: true });
|
||||
this._on(document, 'touchstart', activity, { passive: true });
|
||||
this._on(document, 'click', activity, { passive: true });
|
||||
};
|
||||
|
||||
/** Fin de la plage réellement navigable, en temps média. */
|
||||
XFPlayer.prototype._seekableEnd = function () {
|
||||
var v = this.video;
|
||||
try {
|
||||
if (v.seekable && v.seekable.length > 0) {
|
||||
return v.seekable.end(v.seekable.length - 1);
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (v.buffered && v.buffered.length > 0) {
|
||||
return v.buffered.end(v.buffered.length - 1);
|
||||
}
|
||||
} catch (e) {}
|
||||
return isFinite(v.duration) ? v.duration : 0;
|
||||
};
|
||||
|
||||
/** Durée totale du contenu (et non du seul média chargé), en secondes. */
|
||||
XFPlayer.prototype._totalDuration = function () {
|
||||
var v = this.video;
|
||||
var duration = v.duration;
|
||||
var hlsDur = this._hlsDuration || 0;
|
||||
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
|
||||
duration = hlsDur;
|
||||
} else if (hlsDur > duration) {
|
||||
duration = hlsDur;
|
||||
}
|
||||
|
||||
// Temps absolu : le média peut commencer après le début du contenu.
|
||||
var total = duration > 0 ? this.mediaOffset + duration : 0;
|
||||
|
||||
// La durée annoncée par l'app fait foi tant que le média n'en connaît
|
||||
// qu'une fraction — cas d'une playlist `event` encore en cours de
|
||||
// transcodage, où la durée grandirait sous la barre de progression.
|
||||
// Au-delà, le média a raison : les métadonnées Xtream sont approximatives.
|
||||
if (this.injectedDuration > 0 && total < this.injectedDuration * 0.98) {
|
||||
return this.injectedDuration;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
XFPlayer.prototype._reportPosition = function () {
|
||||
@@ -473,27 +716,60 @@
|
||||
if (!(v.currentTime > 0) || v.paused || v.readyState <= 2) return;
|
||||
if (Math.abs(v.currentTime - this._reportedTime) < 1) return;
|
||||
this._reportedTime = v.currentTime;
|
||||
this._sendPosition();
|
||||
};
|
||||
|
||||
var duration = v.duration;
|
||||
var hlsDur = this._hlsDuration || 0;
|
||||
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
|
||||
duration = hlsDur > 0 ? hlsDur : (this.injectedDuration > 0 ? this.injectedDuration : 0);
|
||||
} else if (hlsDur > duration) {
|
||||
duration = hlsDur;
|
||||
} else if (this.injectedDuration > 0 && duration < this.injectedDuration * 0.9) {
|
||||
duration = this.injectedDuration;
|
||||
}
|
||||
|
||||
/** Envoi immédiat de l'état de lecture (position, durée, zone navigable). */
|
||||
XFPlayer.prototype._sendPosition = function () {
|
||||
this.send({
|
||||
type: 'playback_position',
|
||||
currentTime: v.currentTime,
|
||||
duration: duration
|
||||
currentTime: this.mediaOffset + this.video.currentTime,
|
||||
duration: this._totalDuration(),
|
||||
seekableEnd: this.mediaOffset + this._seekableEnd()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche à un instant ABSOLU du contenu.
|
||||
*
|
||||
* Tant que la cible est dans la zone déjà transcodée, c'est une simple
|
||||
* affectation de `currentTime`. Au-delà, aucune quantité d'attente ne la
|
||||
* rendra disponible rapidement : le parent est prévenu pour relancer le
|
||||
* flux à cet instant (nouvelle session FFmpeg `?start=`).
|
||||
*/
|
||||
XFPlayer.prototype.seekTo = function (absolute) {
|
||||
if (!isFinite(absolute)) return;
|
||||
var v = this.video;
|
||||
var target = absolute - this.mediaOffset;
|
||||
var end = this._seekableEnd();
|
||||
|
||||
if (target < 0) {
|
||||
// Avant le début du média chargé : cette portion n'est pas dans cette
|
||||
// session de transcodage, il faut en relancer une plus tôt.
|
||||
this.send({ type: 'seek_out_of_range', value: Math.max(0, absolute) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Marge de 10 s : juste devant le front de transcodage, attendre coûte
|
||||
// moins cher que relancer un encodeur.
|
||||
if (end > 0 && target > end + 10) {
|
||||
this.send({ type: 'seek_out_of_range', value: absolute });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
v.currentTime = end > 0 ? Math.min(target, Math.max(0, end - 0.5)) : target;
|
||||
} catch (e) {
|
||||
this.send({ type: 'seek_out_of_range', value: absolute });
|
||||
return;
|
||||
}
|
||||
this._reportedTime = v.currentTime;
|
||||
this._sendPosition();
|
||||
};
|
||||
|
||||
XFPlayer.prototype._wireParentMessages = function () {
|
||||
var self = this;
|
||||
global.addEventListener('message', function (event) {
|
||||
this._on(global, 'message', function (event) {
|
||||
// N'accepter que les commandes émises par notre propre origine
|
||||
// (le côté Flutter filtre déjà les messages entrants de la même façon).
|
||||
if (event.origin !== global.location.origin) return;
|
||||
@@ -508,7 +784,12 @@
|
||||
v.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
if (isFinite(d.value)) v.currentTime = d.value;
|
||||
self.seekTo(d.value);
|
||||
break;
|
||||
case 'set_rate':
|
||||
if (isFinite(d.value) && d.value > 0) {
|
||||
try { v.playbackRate = Math.max(0.25, Math.min(4, d.value)); } catch (e) {}
|
||||
}
|
||||
break;
|
||||
case 'set_volume':
|
||||
v.volume = Math.max(0, Math.min(1, d.value));
|
||||
@@ -525,6 +806,10 @@
|
||||
XFPlayer.prototype.destroy = function () {
|
||||
this._destroyed = true;
|
||||
clearInterval(this._posTimer);
|
||||
this._listeners.forEach(function (l) {
|
||||
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
||||
});
|
||||
this._listeners = [];
|
||||
try { if (this.hls) this.hls.destroy(); } catch (e) {}
|
||||
try {
|
||||
if (this.mpegts) {
|
||||
|
||||
Reference in new issue
Block a user