Compare commits

...
Author SHA1 Message Date
Claude e5079127ab Merge remote-tracking branch 'origin/main' into claude/lecture-video
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:57:42 +00:00
LogiFlow 4e453850ae Merge pull request #10 from R0m1k3/claude/nettoyage-qualite
Qualité : builds reproductibles (lockfiles), CI durcie, publication chaînée, docs remises à jour
2026-08-28 08:57:13 +02:00
Claude 1295bd12ef Merge remote-tracking branch 'origin/main' into claude/nettoyage-qualite
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:54:22 +00:00
LogiFlow dfc8a33916 Merge pull request #9 from R0m1k3/claude/fonctionnalites-frontend
Fonctionnalités : guide TV + Season Passes rebranchés, favoris, reprise de lecture, enregistrements mobile
2026-08-28 08:53:40 +02:00
Claude 0541e1b0e3 Merge remote-tracking branch 'origin/main' into claude/fonctionnalites-frontend 2026-08-28 06:50:43 +00:00
LogiFlow 8ba57a7367 Merge pull request #8 from R0m1k3/claude/securite-backend
Sécurité backend : proxy authentifié, SSRF sur redirections, XFF de confiance, logs redactés
2026-08-28 08:50:35 +02:00
Claude 8bed6cf7b8 Merge remote-tracking branch 'origin/main' into claude/fonctionnalites-frontend
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:48:20 +00:00
Claude f6a4fc41da Merge remote-tracking branch 'origin/main' into claude/securite-backend 2026-08-28 06:47:02 +00:00
LogiFlow 46a1710491 Merge pull request #7 from R0m1k3/claude/fiabilite-enregistrements
Fiabilité des enregistrements : UTC de bout en bout, contrôle d'accès, gestion disque, arrêt gracieux
2026-08-28 08:46:52 +02:00
Claude 85a8d16542 Lecture vidéo : son garanti sur toutes les chaînes, zapping et démarrages accélérés
Son manquant sur certaines chaînes :
- Nouvelle route GET /api/live/<id>/turbo.ts pour le player web : vidéo
  copiée (-c:v copy), audio TOUJOURS réencodé en AAC. mpegts.js ne démuxe
  que l'AAC/MP3 : les chaînes en AC-3/E-AC-3/MP2 passaient par le proxy
  brut → image sans son, et le fallback HLS côté client ne couvrait pas
  les codecs démuxés mais non décodables par le navigateur (MP2)
- getLiveStreamUrlTs pointe sur la route turbo ; le proxy brut
  /api/live/<id>.ts reste inchangé pour le scheduler d'enregistrement
- FFmpeg turbo tué dès que le client zappe (onCancel) ; stderr redacté
- Le fallback client (_hlsEquivalent) reconnaît le nouveau format d'URL
  et reste en filet de sécurité

Temps de chargement :
- Players vendorisés mis à jour : hls.js 1.6.7 → 1.7.1,
  mpegts.js 1.7.3 → 1.8.2
- Live HLS et turbo : -fflags nobuffer + probesize/analyzeduration 1 Mo
  (sans borne, FFmpeg pouvait sonder plusieurs secondes avant le premier
  segment)
- VOD : probesize 10 Mo → 5 Mo, analyzeduration 5 s → 2 s
- Live 'high' : preset medium → veryfast (zerolatency déjà actif) ;
  lecture d'enregistrement : medium/crf18 → veryfast/crf20 (medium ne
  tenait pas le temps réel en 1080p sur CPU modeste)
- waitForPlaylist : polling 500 ms → 100 ms
- Suppression du cache-buster &v=timestamp qui re-téléchargeait
  player.html à chaque zap ; les .html sont servis en no-cache côté
  serveur pour garder la fraîcheur après déploiement
- mpegts.js : liveBufferLatencyChasing activé en profil « fast » — les
  micro-coupures ne font plus dériver la lecture derrière le direct

Validé : dart analyze (0 issue) + dart test (48/48) sur bin/, syntaxe JS
vérifiée.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 21:17:27 +00:00
Claude 7fa9897d5c Builds reproductibles, CI durcie, docs remises à jour
Lockfiles :
- pubspec.lock et bin/pubspec.lock versionnés (le .gitignore les excluait
  via *.lock ; le Dockerfile résolvait des versions fraîches à chaque
  build, comme son commentaire l'assumait) ; le Dockerfile les COPY
  désormais, .dockerignore ajusté

CI (ci.yml) :
- flutter-version 3.38.4 et Dart 3.13.2 épinglés (channel: stable seul
  fait dériver le SDK et peut casser la CI sans changement du dépôt)
- cache pub activé, concurrency: cancel-in-progress

Publication (docker-publish.yml) :
- Chaîné sur la CI via workflow_run : l'image :latest n'est publiée
  qu'après analyze/test/build verts sur main (l'ancien push: [main]
  tournait en parallèle et pouvait publier une image cassée) ; build du
  head_sha validé par la CI

docker-compose :
- Défaut RECORDINGS_PATH: ./data/recordings au lieu du chemin unRAID
  spécifique à une machine (/mnt/user/Data/Sport)
- Variable TZ (défaut Europe/Paris)

Docs :
- README réécrit : il décrivait une architecture disparue (Hive/
  IndexedDB, hachage SHA-256, dhttpd port 8080, --web-renderer html)
  au lieu de l'état réel (SQLite serveur, bcrypt, binaire natif port
  8089, enregistrements, transcodage, CI)
- DEPLOYMENT_CHECKLIST.md archivé (6 fichiers cités inexistants,
  versions de dépendances fausses)
- docs/archive/README.md : avertissement que ces documents sont
  historiques (plusieurs se déclarent « COMPLETE » à tort)

Non traité ici : l'épinglage de la release FFmpeg du Dockerfile (l'accès
aux releases BtbN est bloqué depuis cet environnement, impossible de
vérifier un tag valide) et la suppression du code mort (reportée après le
merge des PRs #7/#8/#9 pour éviter les conflits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:33:36 +00:00
Claude aacfa54604 Rebranche les fonctionnalités codées mais inaccessibles
Onglet Enregistrements :
- TabBar à 3 vues : Guide TV (programmer depuis l'EPG), liste des
  enregistrements, Season Passes. _EpgGuideView et _SeasonPassesView
  (~800 lignes fonctionnelles) n'étaient plus instanciés depuis une
  refonte — programmer depuis le guide et les enregistrements
  récurrents étaient devenus inaccessibles
- Confirmation avant suppression d'un enregistrement (la corbeille
  supprimait immédiatement, sans retour ni annulation)
- Erreur de chargement avec bouton Réessayer

Favoris :
- Bouton cœur sur les tuiles chaînes desktop (overlay) et mobile :
  toggleFavorite() n'était appelé nulle part, le filtre « Favoris »
  affichait toujours une liste vide

Reprise de lecture :
- Films et épisodes lisent playbackPositionsProvider et passent
  startTime au player : les positions étaient écrites mais jamais
  relues, tout repartait de zéro
- Plus de sauvegarde de position en live (une entrée bidon par chaîne
  zappée)

Mobile :
- 5e onglet REC réutilisant RecordingsTab
- IndexedStack au lieu du switch : les onglets conservent scroll et
  catalogues chargés (aligné sur le dashboard desktop)

Dashboard :
- Menu profil sur l'avatar de la sidebar (nom d'utilisateur +
  déconnexion) : le bouton était mort, aucune déconnexion possible
  depuis le dashboard desktop
- Erreur de chargement des chaînes avec bouton Réessayer

Validé : flutter analyze (202 issues, identique à la baseline main,
0 erreur/warning), flutter test (OK), flutter build web --release (OK).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:29:23 +00:00
Claude ea63314ba7 Durcit la sécurité du backend et du player
Proxy /api/xtream :
- Authentification de session rétablie (Authorization ou cookie HttpOnly
  session — les requêtes navigateur même-origine le portent) ; le proxy
  était volontairement ouvert, offrant un rebond SSRF non authentifié
- Redirections suivies manuellement avec revalidation à chaque saut
  (hôte privé interdit + allowlist de domaine) : avec followRedirects,
  seule l'URL initiale était validée, une 302 amont suffisait pour
  atteindre un hôte interne
- Erreurs proxy sans détail d'exception (ClientException porte l'URL
  amont, credentials Xtream inclus), logs redactés

Logs :
- redactedLogRequests remplace logRequests() de shelf : l'URI de
  /api/xtream/<url> écrivait username/password Xtream en clair à chaque
  requête, annulant l'effort de LogRedactor partout ailleurs
- Les 500 d'epg_api ne renvoient plus e.toString() au client (même
  risque ClientException) ; détail redacté en log serveur

Middleware :
- X-Forwarded-For honoré uniquement depuis un proxy de confiance
  (loopback + RFC1918 par défaut, surchargables via TRUSTED_PROXIES) :
  un client direct forgeait l'en-tête et contournait le rate limit
  global comme la limite de tentatives de login
- Honeypot comparé sur chemin exact/préfixe : l'ancien
  contains(trap.replaceAll('/','')) bloquait toute URL contenant
  console, env ou wpadmin, y compris des URLs proxifiées légitimes

Comptes :
- Mot de passe admin initial aléatoire (Random.secure, affiché une fois
  au démarrage) ou ADMIN_INITIAL_PASSWORD ; fini le admin/admin persistant
- Longueur minimale de 8 caractères à la création et au changement

Divers :
- CleanupService ne cible plus Directory.systemTemp en récursif (il
  supprimait les temporaires de la VM Dart et le parent des sessions HLS)
- web/xf-player-core.js : postMessage vers location.origin au lieu de
  '*', et filtrage d'event.origin à la réception (le côté Flutter le
  faisait déjà)

Validé : dart analyze (0 issue) et dart test (48/48) sur bin/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:19:58 +00:00
Claude 1d8717bb40 Fiabilise le système d'enregistrement de bout en bout
Fuseaux horaires :
- POST /api/recordings rejette en 400 les dates sans fuseau et normalise
  tout en UTC à l'écriture (l'interprétation des dates naïves dans le TZ
  du conteneur décalait les enregistrements de 1-2 h)
- Helper frontend unique postRecording() : les 2 points de création
  (modal, guide EPG) envoient la même convention UTC

Contrôle d'accès :
- stop/delete/logs d'un enregistrement et delete d'un season pass
  vérifient la propriété (userId ou admin), comme playlists_handler
- Suppression des replis 'dev_user_id' et 'admin' (401 sans session)

SQLite :
- PRAGMA foreign_keys/WAL/busy_timeout (les ON DELETE CASCADE déclarés
  ne s'appliquaient pas : sessions et playlists orphelines)
- Migrations de schéma versionnées (schema_version) + index user_id,
  start_time, season_passes(user_id)

Gestion disque :
- Refus explicite de capture sous MIN_FREE_DISK_MB (défaut 500 Mo)
- Rotation par quota d'octets (RECORDINGS_QUOTA_GB, opt-in) qui ne touche
  jamais un enregistrement actif et supprime fichiers + ligne ensemble ;
  l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours
- DELETE /api/recordings/<id> supprime aussi .mkv/.log/parties (SafePath)

Scheduler :
- Arrêt gracieux orchestré par server.dart : clôture des enregistrements
  (fusion des parties, statut) avant killAll des sessions de streaming ;
  l'ancien handler SIGTERM de FfmpegSessionManager faisait exit(0) direct
- Noms de fichiers uniques par fragment d'id (deux enregistrements du
  même programme s'écrasaient mutuellement avec -y)
- Requête filtrée scheduled/recording au lieu de toute la table / 10 s
- Statut cancelled pour un scheduled arrêté (completed sans fichier
  cassait la lecture)

Season passes :
- Playlist du propriétaire du pass résolue à chaque scan (l'injection
  figée du 1er utilisateur rendait les passes muets après ajout de
  playlist, et mélangeait les credentials en multi-utilisateurs)
- Correspondance exacte par défaut (match_mode, migration en 'contains'
  pour l'existant), plafond de créations par scan, réalignement des
  horaires déplacés dans l'EPG, déduplication tolérante ±2 min
- Redaction des erreurs de scan (ClientException contient l'URL amont)

API de suivi (polling conservé) :
- GET /api/recordings enrichi : progress_pct, file_size_bytes,
  retry_count, is_active ; barre de progression + taille dans la liste

Validé : dart analyze (0 issue) et dart test (48/48) sur bin/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:11:56 +00:00
LogiFlow 60d3cc52d9 Merge pull request #6 from R0m1k3/claude/enregistrement-page-refresh-9z9l45
Rafraîchissement automatique de la page Enregistrements
2026-08-27 13:22:32 +02:00
Claude 488e354bf0 Rafraîchissement automatique de la page Enregistrements
- Nouveau bus de notification (recordingsRefreshBus) : la liste se recharge
  immédiatement dès qu'un enregistrement est créé, planifié ou arrêté depuis
  n'importe où dans l'app (guide EPG, modal, widget rapide)
- Polling en arrière-plan : 5 s quand un enregistrement est en cours ou
  planifié, 20 s sinon, pour suivre les changements de statut sans clic
- Rafraîchissement silencieux (pas de spinner ni de vidage de liste) pour
  éviter le clignotement ; les erreurs transitoires ne remplacent pas la liste
- Indicateur « Suivi auto » avec l'heure de dernière actualisation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 11:15:48 +00:00
41 changed files with 3385 additions and 571 deletions

No files matched your search

-1
View File
@@ -8,7 +8,6 @@
build/
bin/.dart_tool
bin/.packages
bin/pubspec.lock
ios/
android/
windows/
+13 -1
View File
@@ -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,7 +19,13 @@ 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.38.4
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.
@@ -30,7 +42,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
sdk: 3.13.2
- run: dart pub get
- run: dart analyze
- run: dart test
+14 -2
View File
@@ -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
+3
View File
@@ -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
+39
View File
@@ -1,5 +1,44 @@
# 📝 Changelog - XtremFlow Optimisations
## Non publié
### ▶️ Lecture vidéo
- **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
### 🧰 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
- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions
- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule
- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard)
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes
### 🔧 Fiabilité des enregistrements
- **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé
- **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin)
- **SQLite durci** : `foreign_keys=ON` (les CASCADE déclarés s'appliquent enfin), WAL, `busy_timeout`, migrations de schéma versionnées, index sur `user_id`/`start_time`
- **Gestion disque** : refus explicite de démarrer une capture sous `MIN_FREE_DISK_MB` (défaut 500 Mo) ; nouvelle rotation par quota d'octets (`RECORDINGS_QUOTA_GB`, désactivée par défaut) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne BDD ensemble (l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours) ; la suppression d'un enregistrement efface aussi ses fichiers (.mkv, .log, parties)
- **Arrêt gracieux** : `docker stop` clôture proprement les enregistrements (fusion des parties, statut en base) avant de tuer les sessions de streaming
- **Noms de fichiers uniques** (fragment d'id) : deux enregistrements du même programme ne s'écrasent plus
- **Statut `cancelled`** : arrêter un enregistrement planifié l'annule au lieu de le marquer « terminé » sans fichier (lecture cassée)
- **Season passes** : la playlist du propriétaire du pass est résolue à chaque scan (plus d'injection figée du premier utilisateur), correspondance de titre exacte par défaut (`match_mode`), plafond de créations par scan, réalignement automatique des horaires si le programme est déplacé dans l'EPG, déduplication tolérante (±2 min)
- **API de suivi** : `GET /api/recordings` renvoie désormais `progress_pct`, `file_size_bytes`, `retry_count`, `is_active` ; la liste affiche la barre de progression et la taille
- Le scheduler ne relit plus toute la table toutes les 10 s (requête filtrée sur `scheduled`/`recording`)
### 📺 Enregistrements
- La liste des enregistrements se met à jour automatiquement : rafraîchissement immédiat dès qu'un enregistrement est créé/arrêté n'importe où dans l'app (guide EPG, modal, widget rapide), et polling en arrière-plan (5 s quand un enregistrement est en cours ou planifié, 20 s sinon) pour suivre les statuts sans clic manuel
- Indicateur « Suivi auto » avec heure de dernière actualisation dans l'onglet Enregistrements
## Version 1.2 - Security, Streaming & Design Overhaul (10 Juin 2026)
### 🔐 Sécurité
+4 -3
View File
@@ -12,9 +12,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)
+76 -255
View File
@@ -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` | dump FR | 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.
+11 -2
View File
@@ -3,6 +3,7 @@ import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../services/xmltv_epg_service.dart';
import '../utils/log_redactor.dart';
/// API EPG — proxy vers Xtream avec cache 30 minutes
/// GET /api/epg/<channel_id>?days=1
@@ -112,8 +113,11 @@ class EpgApi {
},
);
} catch (e) {
// Détail redacté en log uniquement : une ClientException Dart contient
// l'URI amont, credentials Xtream inclus.
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
headers: {'Content-Type': 'application/json'},
);
}
@@ -258,7 +262,12 @@ class EpgApi {
return {'channel_id': channelId, 'programmes': programmes};
} catch (e) {
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
return {
'channel_id': channelId,
'programmes': [],
'error': 'EPG indisponible',
};
}
}
+74 -15
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../database/database.dart';
import '../middleware/auth_middleware.dart';
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
@@ -31,6 +33,7 @@ bool isForbiddenProxyHost(String host) {
/// Handler for the Xtream Proxy
class ProxyHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final AppDatabase _db;
final http.Client _client = http.Client();
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
@@ -70,7 +73,7 @@ class ProxyHandler {
return playlist;
}
ProxyHandler(this._getPlaylist);
ProxyHandler(this._getPlaylist, this._db);
/// Create Xtream proxy handler with M3U8 URL rewriting support
Handler get handler {
@@ -84,8 +87,16 @@ class ProxyHandler {
return Response.notFound(null);
}
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
// SSRF protection is still active via domain validation below.
// Authentification par session. Les requêtes initiées par le navigateur
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
// rebond SSRF non authentifié vers n'importe quel hôte public via les
// extensions d'image.
final token = extractAuthToken(request);
if (token == null || _db.findSessionByToken(token) == null) {
return Response(401, body: 'Unauthorized');
}
Uri? targetUrl;
@@ -139,6 +150,7 @@ class ProxyHandler {
targetUrl.path.contains('/picons/') ||
targetUrl.path.contains('/logos/');
String? allowedHost;
if (!isStaticAsset) {
// For API calls, enforce domain allowlist
final playlist = await _getCachedPlaylist(request);
@@ -149,7 +161,7 @@ class ProxyHandler {
}
final targetHost = targetUrl.host.toLowerCase();
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
if (targetHost != allowedHost) {
print(
@@ -175,7 +187,6 @@ class ProxyHandler {
try {
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
final proxyRequest = http.Request(request.method, targetUrl);
// Forward safe request headers
for (final header in _allowedRequestHeaders) {
@@ -184,18 +195,61 @@ class ProxyHandler {
}
}
proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = true;
List<int>? postBody;
if (request.method == 'POST') {
final bodyBytes = await request.read().toList();
proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList();
postBody = bodyBytes.expand((i) => i).toList();
}
// Added 90s timeout to allow frontend (60s) to time out gracefully first
final response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
// Redirections suivies MANUELLEMENT : chaque destination est
// revalidée (hôte privé, allowlist de domaine). Avec
// followRedirects, la validation ne portait que sur l'URL
// initiale — une 302 du serveur amont suffisait pour atteindre
// un hôte interne malgré l'anti-SSRF.
http.StreamedResponse response;
var currentUrl = targetUrl;
var redirects = 0;
while (true) {
final proxyRequest = http.Request(request.method, currentUrl);
proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = false;
if (postBody != null) proxyRequest.bodyBytes = postBody;
// Added 90s timeout to allow frontend (60s) to time out gracefully first
response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
final location = response.headers['location'];
final isRedirect = response.statusCode >= 300 &&
response.statusCode < 400 &&
location != null;
if (!isRedirect) break;
if (++redirects > 3) {
return Response.forbidden('Too many redirects');
}
final next = Uri.parse(location);
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
return Response.forbidden('Unsupported redirect scheme');
}
if (isForbiddenProxyHost(currentUrl.host)) {
print(
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
);
return Response.forbidden('Access to this host is forbidden');
}
if (allowedHost != null &&
currentUrl.host.toLowerCase() != allowedHost) {
print(
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
);
return Response.forbidden(
'Access to this domain is forbidden by policy',
);
}
}
// Build response headers from source response
final responseHeaders = <String, String>{
@@ -221,7 +275,12 @@ class ProxyHandler {
rethrow;
}
} catch (e) {
print('[ProxyHandler] error on $path: $e');
// Redaction : une ClientException porte l'URL amont, credentials
// Xtream inclus. Jamais de détail d'exception vers le client.
print(
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
'${LogRedactor.redactUrl('$e')}',
);
// Return transparent 1x1 pixel image fallback for images
if (targetUrl?.path.endsWith('.png') == true ||
@@ -235,7 +294,7 @@ class ProxyHandler {
}
return Response.internalServerError(
body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}),
body: jsonEncode({'error': 'Proxy error'}),
headers: {'content-type': 'application/json'},
);
}
+168 -60
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import '../database/database.dart';
import '../models/recording.dart';
import '../models/user.dart';
import '../services/recording_scheduler.dart';
import '../utils/safe_path.dart';
@@ -11,25 +12,43 @@ class RecordingsApi {
final AppDatabase _db;
final RecordingScheduler _scheduler;
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
final int maxRecordingHours = int.tryParse(
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
) ??
12;
RecordingsApi(this._db, this._scheduler);
Response _json(int status, Map<String, dynamic> body) => Response(
status,
body: json.encode(body),
headers: {'Content-Type': 'application/json'},
);
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
/// (même patron de contrôle de propriété que playlists_handler)
bool _canAccess(User? user, Recording recording) {
if (user == null) return false;
return user.isAdmin || recording.userId == user.id;
}
/// Handler pour GET /api/recordings/logs/<id>
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
Future<Response> getLogHandler(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
if (recording.filePath == null) {
return Response.notFound(
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
}
// Les enregistrements sont écrits en .mkv avec un .log à côté
@@ -39,55 +58,115 @@ class RecordingsApi {
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
if (safeLogPath == null) {
return Response.forbidden(
json.encode({'error': 'Chemin de log invalide'}),
headers: {'Content-Type': 'application/json'},
);
return _json(403, {'error': 'Chemin de log invalide'});
}
final logFile = File(safeLogPath);
if (!await logFile.exists()) {
return Response.notFound(
json.encode({'error': 'Le fichier de log est introuvable.'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Le fichier de log est introuvable.'});
}
final logs = await logFile.readAsString();
return Response.ok(
json.encode({'logs': logs}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'logs': logs});
}
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (tous les enregistrements pour un admin)
/// (tous les enregistrements pour un admin), enrichis des informations de
/// suivi : taille du fichier, progression, relances FFmpeg.
Response handleGetAll(Request request) {
final user = request.context['user'] as User?;
final recordings = (user != null && !user.isAdmin)
? _db.getUserRecordings(user.id)
: _db.getAllRecordings();
final now = DateTime.now().toUtc();
return Response.ok(
json.encode(recordings.map((r) => r.toMap()).toList()),
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
headers: {'Content-Type': 'application/json'},
);
}
Map<String, dynamic> _enrich(Recording r, DateTime now) {
final map = r.toMap();
if (r.status == 'recording') {
final start = r.startTime.toUtc();
final end = r.endTime.toUtc();
final total = end.difference(start).inSeconds;
if (total > 0) {
final elapsed = now.difference(start).inSeconds;
map['progress_pct'] =
(elapsed * 100 / total).clamp(0, 100).round();
}
map['is_active'] = _scheduler.isCapturing(r.id);
final retries = _scheduler.retryCountOf(r.id);
if (retries != null) map['retry_count'] = retries;
}
final path = r.filePath;
if (path != null) {
try {
final file = File(path);
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
} catch (_) {}
}
return map;
}
/// POST /api/recordings — Planifie un nouvel enregistrement
Future<Response> handlePost(Request request) async {
try {
final payload = await request.readAsString();
final data = json.decode(payload);
final user = request.context['user'] as User?;
final userId = user?.id ?? request.context['userId'] as String?;
if (userId == null) {
return _json(401, {'error': 'Authentification requise'});
}
final userId = request.context['userId'] as String? ?? 'dev_user_id';
Map<String, dynamic> data;
try {
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
} catch (_) {
return _json(400, {'error': 'Corps JSON invalide'});
}
final channelId = data['channel_id']?.toString() ?? '';
final streamUrl = data['stream_url']?.toString() ?? '';
if (channelId.isEmpty) {
return _json(400, {'error': 'channel_id est requis'});
}
if (streamUrl.isEmpty) {
return _json(400, {'error': 'stream_url est requis'});
}
final startTime = _parseZonedDate(data['start_time']);
final endTime = _parseZonedDate(data['end_time']);
if (startTime == null || endTime == null) {
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
// l'interpréter dans le fuseau du serveur décale l'enregistrement
// de plusieurs heures selon le TZ du conteneur.
return _json(400, {
'error':
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
'(ex: 2026-08-27T21:00:00Z)',
});
}
if (!endTime.isAfter(startTime)) {
return _json(400, {'error': 'end_time doit être après start_time'});
}
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
return _json(400, {
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
});
}
try {
final recording = _db.createRecording(
userId: userId,
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Sans Titre',
startTime: DateTime.parse(data['start_time']),
endTime: DateTime.parse(data['end_time']),
channelId: channelId,
streamUrl: streamUrl,
title: data['title']?.toString() ?? 'Sans Titre',
startTime: startTime,
endTime: endTime,
);
return Response.ok(
@@ -95,58 +174,87 @@ class RecordingsApi {
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la programmation: $e'}),
headers: {'Content-Type': 'application/json'},
);
print('[RecordingsApi] Erreur à la création: $e');
return _json(500, {'error': 'Erreur lors de la programmation'});
}
}
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
DateTime? _parseZonedDate(dynamic raw) {
final str = raw?.toString() ?? '';
if (str.isEmpty) return null;
// 'Z' final ou offset ±hh[:mm] après l'heure
final hasZone =
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
if (!hasZone) return null;
return DateTime.tryParse(str)?.toUtc();
}
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
Future<Response> handleDelete(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
await _scheduler.stopRecording(id);
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
// en restant confiné au dossier des enregistrements.
final path = recording.filePath;
if (path != null) {
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
if (safePath != null) {
await _scheduler.deleteRecordingFiles(safePath);
}
}
_db.deleteRecording(id);
return Response.ok(
json.encode({'message': 'Enregistrement supprimé avec succès'}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
}
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
Future<Response> handleStop(Request request, String id) async {
final recording = _db.getRecordingById(id);
if (recording == null) {
return Response.notFound(
json.encode({'error': 'Enregistrement non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(404, {'error': 'Enregistrement non trouvé'});
}
final user = request.context['user'] as User?;
if (!_canAccess(user, recording)) {
return _json(403, {'error': 'Accès refusé'});
}
final stopped = await _scheduler.stopRecording(id);
if (stopped) {
return Response.ok(
json.encode({'message': 'Enregistrement arrêté'}),
headers: {'Content-Type': 'application/json'},
);
} else {
// Pas de processus FFmpeg actif pour cet ID → marquer comme complété quand même
_db.updateRecordingStatus(id, 'completed');
return Response.ok(
json.encode({'message': 'Enregistrement marqué comme terminé'}),
headers: {'Content-Type': 'application/json'},
);
return _json(200, {'message': 'Enregistrement arrêté'});
}
if (recording.status == 'scheduled') {
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
// completed sans fichier faisait ensuite échouer la lecture.
_db.updateRecordingStatus(id, 'cancelled');
return _json(200, {'message': 'Enregistrement annulé'});
}
if (recording.status == 'recording') {
// Statut « recording » sans processus actif (orphelin) : clôturer.
_db.updateRecordingStatus(id, 'completed');
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
}
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
return _json(200, {'message': 'Enregistrement déjà clôturé'});
}
}
+47 -8
View File
@@ -9,17 +9,29 @@ class SeasonPassesApi {
SeasonPassesApi(this._db);
/// GET /api/season-passes — liste tous les season passes
/// GET /api/season-passes — liste les season passes de l'utilisateur
/// (tous les passes pour un admin)
Response handleGetAll(Request request) {
try {
final passes = _db.getAllSeasonPasses();
final user = request.context['user'] as User?;
if (user == null) {
return Response(
401,
body: json.encode({'error': 'Authentification requise'}),
headers: {'Content-Type': 'application/json'},
);
}
final passes = user.isAdmin
? _db.getAllSeasonPasses()
: _db.getSeasonPassesForUser(user.id);
return Response.ok(
json.encode(passes),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
print('[SeasonPass] Erreur au listage: $e');
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
body: json.encode({'error': 'Erreur interne'}),
headers: {'Content-Type': 'application/json'},
);
}
@@ -59,10 +71,20 @@ class SeasonPassesApi {
// Récupérer l'utilisateur depuis le contexte
final user = request.context['user'] as User?;
final userId = user?.id ?? 'admin'; // fallback
if (user == null) {
return Response(
401,
body: json.encode({'error': 'Authentification requise'}),
headers: {'Content-Type': 'application/json'},
);
}
// Vérifier si un season pass identique existe déjà
final existing = _db.getAllSeasonPasses();
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot. 'contains' reste disponible.
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
// Vérifier si un season pass identique existe déjà pour cet utilisateur
final existing = _db.getSeasonPassesForUser(user.id);
final duplicate = existing.any(
(p) =>
(p['show_title'] as String).toLowerCase() ==
@@ -78,10 +100,11 @@ class SeasonPassesApi {
}
final pass = _db.createSeasonPass(
userId: userId,
userId: user.id,
showTitle: showTitle,
channelId: channelId,
streamUrl: streamUrl,
matchMode: matchMode,
);
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
@@ -101,14 +124,30 @@ class SeasonPassesApi {
/// DELETE /api/season-passes/<id> — supprimer un season pass
Response handleDelete(Request request, String id) {
try {
final user = request.context['user'] as User?;
final pass = _db.getSeasonPassById(id);
if (pass == null) {
return Response.notFound(
json.encode({'error': 'Season Pass non trouvé'}),
headers: {'Content-Type': 'application/json'},
);
}
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
return Response.forbidden(
json.encode({'error': 'Accès refusé'}),
headers: {'Content-Type': 'application/json'},
);
}
_db.deleteSeasonPass(id);
return Response.ok(
json.encode({'message': 'Season Pass supprimé'}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
print('[SeasonPass] Erreur à la suppression: $e');
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
body: json.encode({'error': 'Erreur interne'}),
headers: {'Content-Type': 'application/json'},
);
}
+99 -4
View File
@@ -94,7 +94,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',
@@ -318,6 +322,11 @@ 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),
..._audioArgs(withFilters: false),
@@ -364,6 +373,85 @@ 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)}',
);
final 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',
]);
// 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 ; tuer FFmpeg dès que le client zappe
// ou ferme l'onglet (sinon les processus s'accumulent à chaque zap).
final controller = StreamController<List<int>>();
final subscription = process.stdout.listen(
controller.add,
onError: controller.addError,
onDone: controller.close,
);
controller.onCancel = () {
subscription.cancel();
process.kill(ProcessSignal.sigterm);
};
return Response(
200,
body: controller.stream,
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);
@@ -464,8 +552,11 @@ 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),
..._audioArgs(withFilters: quality != 'source'),
@@ -586,7 +677,11 @@ Handler createRecordingStreamHandler(
'-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',
],
+14
View File
@@ -65,6 +65,13 @@ class UsersHandler {
}), headers: {'Content-Type': 'application/json'},);
}
if (password.length < 8) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Le mot de passe doit faire au moins 8 caractères',
}), headers: {'Content-Type': 'application/json'},);
}
if (db.findUserByUsername(username) != null) {
return Response.badRequest(body: jsonEncode({
'success': false,
@@ -103,6 +110,13 @@ class UsersHandler {
}), headers: {'Content-Type': 'application/json'},);
}
if (password.length < 8) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Le mot de passe doit faire au moins 8 caractères',
}), headers: {'Content-Type': 'application/json'},);
}
db.updateUserPassword(id, password);
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
+200 -21
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math';
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import '../models/user.dart';
@@ -23,10 +24,68 @@ class AppDatabase {
_db = sqlite3.open(dbPath);
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
// (donc des jetons valides), playlists et enregistrements orphelins.
_db.execute('PRAGMA foreign_keys = ON');
_db.execute('PRAGMA journal_mode = WAL');
_db.execute('PRAGMA busy_timeout = 5000');
await _createTables();
_runMigrations();
print('Database initialized: $dbPath');
}
/// Migrations de schéma pour les bases créées par une version antérieure.
///
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
/// existante : chaque colonne introduite après coup doit avoir sa migration.
/// Les migrations sont numérotées et rejouées uniquement si nécessaire.
void _runMigrations() {
_db.execute('''
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
)
''');
final result = _db.select(
'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version',
);
var current = result.first['v'] as int;
final migrations = <int, void Function()>{
// v1 : colonne error_reason absente des bases d'avant son introduction.
1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'),
// v2 : mode de correspondance des season passes. 'contains' pour les
// lignes existantes (comportement historique) ; les nouvelles créations
// passent par l'API qui choisit 'exact' par défaut.
2: () => _addColumnIfMissing(
'season_passes',
'match_mode',
"TEXT NOT NULL DEFAULT 'contains'",
),
};
for (final entry in migrations.entries) {
if (entry.key <= current) continue;
entry.value();
_db.execute(
'INSERT INTO schema_version (version) VALUES (?)',
[entry.key],
);
current = entry.key;
print('[DB] Migration v${entry.key} appliquée');
}
}
void _addColumnIfMissing(String table, String column, String definition) {
final columns = _db.select('PRAGMA table_info($table)');
final exists = columns.any((row) => row['name'] == column);
if (!exists) {
_db.execute('ALTER TABLE $table ADD COLUMN $column $definition');
}
}
/// Create database tables
Future<void> _createTables() async {
// Users table
@@ -107,6 +166,7 @@ class AppDatabase {
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
match_mode TEXT NOT NULL DEFAULT 'exact',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
@@ -125,16 +185,32 @@ class AppDatabase {
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)',
);
}
/// Seed default admin user if no users exist
/// Seed default admin user if no users exist.
///
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
/// couple admin/admin restait souvent en place sur les instances exposées.
Future<void> seedAdmin() async {
final result = _db.select('SELECT COUNT(*) as count FROM users');
final count = result.first['count'] as int;
if (count == 0) {
final adminId = _uuid.v4();
final passwordHash = PasswordHasher.hash('admin');
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
final generated = envPassword == null || envPassword.isEmpty;
final password = generated ? _generatePassword() : envPassword;
final passwordHash = PasswordHasher.hash(password);
_db.execute(
'''
@@ -144,10 +220,34 @@ class AppDatabase {
[adminId, 'admin', passwordHash],
);
print('Default admin user created (username: admin, password: admin)');
if (generated) {
print('╔══════════════════════════════════════════════════════════╗');
print(' Compte admin créé — mot de passe initial (affiché une');
print(' seule fois, changez-le après la première connexion) :');
print(' utilisateur: admin');
print(' mot de passe: $password');
print('╚══════════════════════════════════════════════════════════╝');
} else {
print(
'Default admin user created (username: admin, '
'password: ADMIN_INITIAL_PASSWORD)',
);
}
}
}
static String _generatePassword({int length = 16}) {
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
// depuis les logs du conteneur.
const chars =
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
final random = Random.secure();
return List.generate(
length,
(_) => chars[random.nextInt(chars.length)],
).join();
}
// ==================== Users ====================
/// Find user by username
@@ -473,6 +573,11 @@ class AppDatabase {
final recordingId = _uuid.v4();
final now = DateTime.now().toIso8601String();
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du scheduler
// et la déduplication des season passes reposent sur ce format unique.
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
_db.execute(
'''
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
@@ -484,8 +589,8 @@ class AppDatabase {
channelId,
streamUrl,
title,
startTime.toIso8601String(),
endTime.toIso8601String(),
startUtc.toIso8601String(),
endUtc.toIso8601String(),
now,
now,
],
@@ -497,21 +602,41 @@ class AppDatabase {
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
startTime: startUtc,
endTime: endUtc,
status: 'scheduled',
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
/// Lister tous les enregistrements (pour l'admin)
List<Recording> getAllRecordings() {
final result =
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
/// Évite de désérialiser tout l'historique toutes les 10 secondes.
List<Recording> getPendingRecordings() {
final result = _db.select(
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') "
'ORDER BY start_time ASC',
);
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Enregistrements terminés (completed/failed/cancelled) du plus ancien au
/// plus récent — utilisé par la rotation disque.
List<Recording> getFinishedRecordingsOldestFirst() {
final result = _db.select(
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') "
'ORDER BY start_time ASC',
);
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Lister les enregistrements d'un utilisateur spécifique
List<Recording> getUserRecordings(String userId) {
final result = _db.select(
@@ -551,6 +676,24 @@ class AppDatabase {
);
}
/// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé
/// dans l'EPG depuis sa création par un season pass).
void updateRecordingWindow(String id, DateTime start, DateTime end) {
_db.execute(
'''
UPDATE tv_recordings
SET start_time = ?, end_time = ?, updated_at = ?
WHERE id = ?
''',
[
start.toUtc().toIso8601String(),
end.toUtc().toIso8601String(),
DateTime.now().toIso8601String(),
id,
],
);
}
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
void deleteRecording(String id) {
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
@@ -564,12 +707,13 @@ class AppDatabase {
required String showTitle,
required String channelId,
required String streamUrl,
String matchMode = 'exact',
}) {
final id = _uuid.v4();
final now = DateTime.now().toIso8601String();
_db.execute(
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, now],
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, userId, showTitle, channelId, streamUrl, matchMode, now],
);
return {
'id': id,
@@ -577,12 +721,13 @@ class AppDatabase {
'show_title': showTitle,
'channel_id': channelId,
'stream_url': streamUrl,
'match_mode': matchMode,
'enabled': 1,
'created_at': now,
};
}
/// Lister tous les Season Passes
/// Lister les Season Passes actifs (pour le scheduler)
List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
@@ -590,23 +735,57 @@ class AppDatabase {
return result.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
List<Map<String, dynamic>> getSeasonPassesForUser(String userId) {
final result = _db.select(
'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC',
[userId],
);
return result.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
Map<String, dynamic>? getSeasonPassById(String id) {
final result =
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
if (result.isEmpty) return null;
return Map<String, dynamic>.from(result.first);
}
/// Supprimer un Season Pass
void deleteSeasonPass(String id) {
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
}
/// Vérifier si un enregistrement existe déjà pour ce titre (déduplication)
/// Retourne true si un enregistrement non-échoué avec ce titre existe pour cetteémission programméeà la même heure
bool existsRecordingForEpisode(String title, DateTime startTime) {
// Normaliser le titre pour la comparaison (insensible casse, sans espaces doubles)
/// Cherche un enregistrement existant pour cet épisode (déduplication des
/// season passes) : même titre, début à ±[tolerance] près.
///
/// La comparaison par plage remplace l'ancienne égalité de chaîne, qui
/// échouait dès que le format stocké différait (avec/sans 'Z') ou que le
/// panneau décalait le programme de quelques secondes — l'épisode était
/// alors réenregistré en double.
Recording? findRecordingForEpisode(
String title,
DateTime startTime, {
Duration tolerance = const Duration(minutes: 2),
}) {
final startUtc = startTime.toUtc();
// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
final result = _db.select(
'''SELECT COUNT(*) as cnt FROM tv_recordings
WHERE LOWER(title) = LOWER(?)
AND start_time = ?
AND status NOT IN ('failed')''',
[title, startTime.toUtc().toIso8601String()],
'''SELECT * FROM tv_recordings
WHERE LOWER(title) = LOWER(?)
AND start_time BETWEEN ? AND ?
AND status NOT IN ('failed', 'cancelled')
LIMIT 1''',
[
title,
startUtc.subtract(tolerance).toIso8601String(),
startUtc.add(tolerance).toIso8601String(),
],
);
return (result.first['cnt'] as int) > 0;
if (result.isEmpty) return null;
return Recording.fromMap(result.first);
}
/// Close database connection
+6 -1
View File
@@ -77,7 +77,12 @@ Middleware streamAuthMiddleware(AppDatabase db) {
};
}
/// Extract token from Authorization header or cookie
/// Extract token from Authorization header or cookie.
/// Public : le proxy /api/xtream fait sa propre vérification de session
/// (le contrôle doit rester DANS le handler, après le test de chemin,
/// pour que les requêtes non-proxy tombent sur le handler statique).
String? extractAuthToken(Request request) => _extractToken(request);
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
+82 -11
View File
@@ -1,26 +1,94 @@
import 'package:shelf/shelf.dart';
import 'dart:async';
import 'dart:io';
import '../utils/log_redactor.dart';
/// Security Middleware Collection
///
/// Includes:
/// - Redacted request logging
/// - Honeypot Routes (Trap for bots)
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
/// - Rate Limiting (Basic DoS protection)
/// - Login-specific rate limiting (brute-force protection)
/// Resolve the real client IP.
/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise
/// falls back to the socket connection info.
String clientIpOf(Request request) {
final forwarded = request.headers['x-forwarded-for'];
if (forwarded != null && forwarded.isNotEmpty) {
return forwarded.split(',').first.trim();
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
/// docker-compose parle depuis le réseau Docker). Surcharger avec
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
final List<String> _trustedProxies =
(Platform.environment['TRUSTED_PROXIES'] ?? '')
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
bool _isTrustedProxy(String address) {
if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address);
final ip = InternetAddress.tryParse(address);
if (ip == null) return false;
if (ip.isLoopback) return true;
if (ip.type == InternetAddressType.IPv4) {
final parts = ip.address.split('.').map(int.parse).toList();
if (parts[0] == 10) return true;
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] == 192 && parts[1] == 168) return true;
}
return false;
}
/// Resolve the real client IP.
///
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
/// contourner le rate limit global comme la limite de tentatives de login.
String clientIpOf(Request request) {
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
return connectionInfo?.remoteAddress.address ?? 'unknown';
final socketAddress = connectionInfo?.remoteAddress.address;
final forwarded = request.headers['x-forwarded-for'];
if (forwarded != null &&
forwarded.isNotEmpty &&
socketAddress != null &&
_isTrustedProxy(socketAddress)) {
return forwarded.split(',').first.trim();
}
return socketAddress ?? 'unknown';
}
/// 0. Redacted request logging.
///
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
Middleware redactedLogRequests() {
return (Handler handler) {
return (Request request) async {
final watch = Stopwatch()..start();
try {
final response = await handler(request);
watch.stop();
final query =
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
print(
'${DateTime.now().toIso8601String()} ${response.statusCode} '
'${request.method} '
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
'(${watch.elapsedMilliseconds}ms)',
);
return response;
} catch (e) {
watch.stop();
print(
'${DateTime.now().toIso8601String()} ERR ${request.method} '
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
'${LogRedactor.redactUrl('$e')}',
);
rethrow;
}
};
};
}
/// 1. Security Headers Middleware
@@ -71,11 +139,14 @@ Middleware honeypotMiddleware() {
return (Handler handler) {
return (Request request) {
final path = request.url.path;
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
// contenant « console », « env » ou « wpadmin » n'importe où — y
// compris des URLs proxifiées parfaitement légitimes.
final path = '/${request.url.path}';
// Check if path contains any honeypot target
for (final trap in honeypotPaths) {
if (path.contains(trap.replaceAll('/', ''))) { // Simple check
if (path == trap || path.startsWith('$trap/')) {
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
return Response.forbidden('Access Denied');
}
+477
View File
@@ -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.11.0 <4.0.0"
+33 -23
View File
@@ -42,31 +42,37 @@ void main(List<String> args) async {
await db.seedAdmin();
// Initialize and start Recording Scheduler
// (les Season Passes résolvent la playlist de leur propriétaire à chaque
// scan : plus d'injection figée du premier utilisateur au démarrage)
final recordingScheduler = RecordingScheduler(db);
recordingScheduler.start();
// Injecter la config playlist dans le scheduler pour les Season Passes
// (on prend la playlist du premier utilisateur disponible)
Future<void> injectPlaylistToScheduler() async {
final users = db.getAllUsers();
if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id);
if (playlists.isNotEmpty) {
final p = playlists.first;
recordingScheduler.playlistDns = p.serverUrl;
recordingScheduler.playlistUsername = p.username;
recordingScheduler.playlistPassword = p.password;
print('[Server] Playlist injectée dans le scheduler: ${p.name}');
}
}
}
// Injecter après 5s pour attendre l'initialisation complète
Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
// Initialize Streaming Subsystem
await initStreaming();
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
// enregistrements (kill FFmpeg, fusion des parties, statut en base), puis
// les sessions de streaming, puis sortir. Sans cela les enregistrements
// restaient au statut « recording » et la reprise d'orphelins devait
// systématiquement rattraper au redémarrage.
var shuttingDown = false;
Future<void> shutdownServer(String signal) async {
if (shuttingDown) return;
shuttingDown = true;
print('[Server] $signal reçu, arrêt en cours…');
try {
await recordingScheduler.shutdown();
} catch (e) {
print('[Server] Erreur à l\'arrêt du scheduler: $e');
}
sessionManager.killAll();
db.close();
exit(0);
}
ProcessSignal.sigterm.watch().listen((_) => shutdownServer('SIGTERM'));
ProcessSignal.sigint.watch().listen((_) => shutdownServer('SIGINT'));
// Helper to get playlist from request
Future<PlaylistConfig?> getPlaylist(Request request) async {
Playlist? playlist;
@@ -114,7 +120,7 @@ void main(List<String> args) async {
final playlistsHandler = PlaylistsHandler(db);
final usersHandler = UsersHandler(db);
final settingsHandler = SettingsHandler(db);
final proxyHandler = ProxyHandler(getPlaylist);
final proxyHandler = ProxyHandler(getPlaylist, db);
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é.
@@ -216,8 +222,10 @@ void main(List<String> args) async {
// Do NOT mount here as it would intercept and block the actual proxy
// Initialize Cleanup Service
// Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les
// temporaires de la VM Dart et le dossier des sessions HLS — les fichiers
// de plus de 24 h y étaient supprimés aveuglément.
final cleanupService = CleanupService();
cleanupService.addTarget(Directory.systemTemp);
cleanupService.addTarget(Directory('/app/data/logs'));
cleanupService.addTarget(Directory('/app/data/tmp'));
@@ -269,7 +277,7 @@ void main(List<String> args) async {
final isVendored = path.startsWith('vendor/');
if (!isVendored &&
(path.isEmpty ||
path == 'index.html' ||
path.endsWith('.html') ||
path.endsWith('.js') ||
path.endsWith('.json'))) {
return response.change(
@@ -332,8 +340,10 @@ void main(List<String> args) async {
.handler;
// Add middleware
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
// contient username/password Xtream en clair.
final pipeline = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(redactedLogRequests())
.addMiddleware(securityHeadersMiddleware())
.addMiddleware(honeypotMiddleware())
.addMiddleware(rateLimitMiddleware())
+7 -10
View File
@@ -59,15 +59,10 @@ class FfmpegSessionManager {
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
// Clean shutdown for docker stop / Ctrl+C
ProcessSignal.sigterm.watch().listen((_) {
killAll();
exit(0);
});
ProcessSignal.sigint.watch().listen((_) {
killAll();
exit(0);
});
// L'arrêt propre (docker stop / Ctrl+C) est orchestré par server.dart :
// il clôture d'abord les enregistrements puis appelle killAll(). Un
// handler local qui ferait exit(0) immédiatement court-circuiterait
// cette clôture.
}
FfmpegSession? get(String id) => _sessions[id];
@@ -170,7 +165,9 @@ class FfmpegSessionManager {
'${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));
}
return (ready: false, error: 'Timeout waiting for transcoder');
}
+223 -47
View File
@@ -127,14 +127,34 @@ class RecordingScheduler {
) ??
2;
// Playlist config pour les appels EPG des season passes
// Rempli depuis server.dart après initialisation
String? playlistDns;
String? playlistUsername;
String? playlistPassword;
/// Espace libre minimal (Mo) exigé pour démarrer une capture.
final int minFreeDiskMb = int.tryParse(
Platform.environment['MIN_FREE_DISK_MB'] ?? '',
) ??
500;
/// Quota du dossier d'enregistrements en Go (0 = rotation désactivée).
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait
/// aveuglément, y compris des enregistrements en cours d'écriture.
final int recordingsQuotaGb = int.tryParse(
Platform.environment['RECORDINGS_QUOTA_GB'] ?? '',
) ??
0;
/// Nombre maximal d'enregistrements créés par season pass et par scan.
final int seasonPassMaxPerScan = int.tryParse(
Platform.environment['SEASON_PASS_MAX_PER_SCAN'] ?? '',
) ??
10;
RecordingScheduler(this._db);
/// Un enregistrement est-il activement capturé par un processus FFmpeg ?
bool isCapturing(String id) => _active.containsKey(id);
/// Nombre de relances FFmpeg de l'enregistrement actif [id] (null si inactif).
int? retryCountOf(String id) => _active[id]?.consecutiveFailures;
void start() {
print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
@@ -163,6 +183,31 @@ class RecordingScheduler {
print('[RecordingScheduler] Arrêté');
}
/// Arrêt gracieux pour un `docker stop` : arrête les timers, clôt chaque
/// enregistrement actif (kill FFmpeg, fusion des parties, statut en base)
/// et attend la fin des clôtures dans la limite de [timeout].
///
/// Sans cette attente, le conteneur meurt avant la clôture : les
/// enregistrements restent au statut « recording » et la reprise d'orphelins
/// doit systématiquement rattraper au redémarrage.
Future<void> shutdown({Duration timeout = const Duration(seconds: 8)}) async {
_timer?.cancel();
_seasonPassTimer?.cancel();
final closings = <Future<void>>[];
for (final id in _active.keys.toList()) {
final future =
_stopActiveRecording(id, reason: 'Arrêt du serveur');
if (future != null) closings.add(future);
}
if (closings.isNotEmpty) {
print(
'[RecordingScheduler] Clôture de ${closings.length} enregistrement(s)…',
);
await Future.wait(closings).timeout(timeout, onTimeout: () => const []);
}
print('[RecordingScheduler] Arrêté proprement');
}
Future<void> _checkAndRunRecordings() async {
if (_isRunning) return;
_isRunning = true;
@@ -185,7 +230,7 @@ class RecordingScheduler {
// Lire la base APRÈS les arrêts : un instantané pris avant ferait passer
// l'enregistrement tout juste terminé pour un orphelin (il n'est plus
// dans `_active` alors que l'instantané le dit encore « recording »).
final recordings = _db.getAllRecordings();
final recordings = _db.getPendingRecordings();
// Rechercher les enregistrements planifiés
for (final recording in recordings) {
@@ -286,21 +331,22 @@ class RecordingScheduler {
}
}
/// Normalise un titre pour la correspondance : minuscules, espaces réduits.
static String _normalizeTitle(String title) =>
title.toLowerCase().trim().replaceAll(RegExp(r'\s+'), ' ');
/// Le titre EPG [title] correspond-il au pass selon son [matchMode] ?
static bool _titleMatches(String title, String showTitle, String matchMode) {
final t = _normalizeTitle(title);
final s = _normalizeTitle(showTitle);
if (s.isEmpty) return false;
return matchMode == 'contains' ? t.contains(s) : t == s;
}
Future<void> _checkSeasonPasses() async {
final passes = _db.getAllSeasonPasses();
if (passes.isEmpty) return;
final dns = playlistDns;
final username = playlistUsername;
final password = playlistPassword;
if (dns == null || username == null || password == null) {
print(
'[SeasonPass] Config playlist non disponible, vérification annulée',
);
return;
}
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
for (final pass in passes) {
@@ -309,9 +355,28 @@ class RecordingScheduler {
final streamUrl = pass['stream_url'] as String;
final showTitle = pass['show_title'] as String;
final userId = pass['user_id'] as String;
final matchMode = pass['match_mode'] as String? ?? 'contains';
// Récupérer l'EPG de la chaîne (48 prochaines heures)
final url = '$dns/player_api.php?username=$username&password=$password'
// Résoudre la playlist du PROPRIÉTAIRE du pass au moment du scan.
// L'ancienne config injectée au démarrage venait du premier utilisateur
// de la base et n'était jamais réactualisée : une playlist ajoutée
// après coup rendait les passes muets, et en multi-utilisateurs les
// credentials de l'un servaient aux passes d'un autre.
final playlists = _db.getPlaylists(userId);
if (playlists.isEmpty) {
print(
'[SeasonPass] Aucune playlist pour le propriétaire du pass '
'"$showTitle", scan ignoré',
);
continue;
}
final playlist = playlists.first;
// Récupérer l'EPG de la chaîne (les prochains programmes ; `limit`
// est un nombre de programmes, pas des heures)
final url =
'${playlist.serverUrl}/player_api.php?username=${playlist.username}'
'&password=${playlist.password}'
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
final response =
@@ -322,14 +387,14 @@ class RecordingScheduler {
final listings =
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
var createdThisScan = 0;
for (final item in listings) {
String title = item['title'] as String? ?? '';
try {
title = utf8.decode(base64Decode(title));
} catch (_) {}
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
if (!_titleMatches(title, showTitle, matchMode)) continue;
// Parser les heures de début/fin
final startStr = item['start'] as String? ?? '';
@@ -348,12 +413,31 @@ class RecordingScheduler {
// Ne pas créer pour les programmes déjà terminés
if (endTime.isBefore(DateTime.now().toUtc())) continue;
// Déduplication : vérifier si cet épisode est déjà planifié/enregistré
if (_db.existsRecordingForEpisode(title, startTime)) {
print('[SeasonPass] "$title" déjà enregistré, skip.');
// Déduplication : cet épisode est-il déjà planifié/enregistré ?
final existing = _db.findRecordingForEpisode(title, startTime);
if (existing != null) {
// Programme déplacé dans l'EPG depuis la planification :
// réaligner la fenêtre tant que la capture n'a pas commencé.
if (existing.status == 'scheduled' &&
(existing.startTime.toUtc() != startTime ||
existing.endTime.toUtc() != endTime)) {
_db.updateRecordingWindow(existing.id, startTime, endTime);
print(
'[SeasonPass] "$title" réaligné sur le nouvel horaire '
'${startTime.toLocal()}',
);
}
continue;
}
if (createdThisScan >= seasonPassMaxPerScan) {
print(
'[SeasonPass] Plafond de $seasonPassMaxPerScan créations atteint '
'pour "$showTitle" sur ce scan',
);
break;
}
// Créer l'enregistrement automatiquement
_db.createRecording(
userId: userId,
@@ -363,12 +447,18 @@ class RecordingScheduler {
startTime: startTime,
endTime: endTime,
);
createdThisScan++;
print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
);
}
} catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
// Ne jamais imprimer l'exception brute : une ClientException peut
// contenir l'URL amont avec les credentials Xtream.
print(
'[SeasonPass] Erreur pour le pass "${pass['show_title']}": '
'${LogRedactor.redactUrl('$e')}',
);
}
}
}
@@ -392,6 +482,24 @@ class RecordingScheduler {
// Nettoyer l'espace disque si nécessaire
await _checkDiskSpaceAndRotate(recordingsDir);
// Refuser de démarrer sur un volume plein : mieux vaut un échec
// explicite immédiat qu'une capture qui meurt à mi-parcours.
final freeBytes = await _freeDiskBytes(recordingsDir.path);
if (freeBytes != null && freeBytes < minFreeDiskMb * 1024 * 1024) {
final freeMb = freeBytes ~/ (1024 * 1024);
print(
'[RecordingScheduler] Espace disque insuffisant ($freeMb Mo libres, '
'minimum $minFreeDiskMb Mo) : "${recording.title}" refusé',
);
_db.updateRecordingStatus(
recording.id,
'failed',
errorReason:
'Espace disque insuffisant ($freeMb Mo libres, minimum $minFreeDiskMb Mo)',
);
return;
}
final filePath = p.join(recordingsDir.path, _fileNameFor(recording));
final logPath = p.setExtension(filePath, '.log');
@@ -673,9 +781,11 @@ class RecordingScheduler {
return false; // Pas d'enregistrement actif avec cet ID
}
void _stopActiveRecording(String id, {String? reason}) {
/// Arrête l'enregistrement actif [id] et retourne la future de clôture
/// (fusion des parties + statut), ou null si aucun n'est actif.
Future<void>? _stopActiveRecording(String id, {String? reason}) {
final active = _active.remove(id);
if (active == null) return;
if (active == null) return null;
active.stopping = true;
if (reason != null) {
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
@@ -685,7 +795,9 @@ class RecordingScheduler {
);
active.process?.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed');
unawaited(_finalizeStopped(active));
final closing = _finalizeStopped(active);
unawaited(closing);
return closing;
}
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
@@ -709,7 +821,9 @@ class RecordingScheduler {
}
String _fileNameFor(Recording recording) {
// Génération d'un nom de fichier unique et sûr
// Génération d'un nom de fichier unique et sûr. Le fragment d'id garantit
// l'unicité : deux utilisateurs enregistrant le même programme sur la même
// chaîne s'écrasaient mutuellement (FFmpeg est lancé avec -y).
final safeTitle =
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
final dateStr = recording.startTime
@@ -717,7 +831,10 @@ class RecordingScheduler {
.toIso8601String()
.replaceAll(':', '')
.split('.')[0];
return '${safeTitle}_$dateStr.mkv';
final idFragment = recording.id.length >= 8
? recording.id.substring(0, 8)
: recording.id;
return '${safeTitle}_${dateStr}_$idFragment.mkv';
}
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
@@ -755,28 +872,87 @@ class RecordingScheduler {
} catch (_) {}
}
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
/// Espace libre (octets) sur le volume qui porte [path], ou null si `df`
/// n'est pas disponible.
Future<int?> _freeDiskBytes(String path) async {
try {
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
final files = dir.listSync().whereType<File>().toList();
final result = await Process.run('df', ['-B1', '--output=avail', path]);
if (result.exitCode != 0) return null;
final lines = (result.stdout as String).trim().split('\n');
return int.tryParse(lines.last.trim());
} catch (_) {
return null;
}
}
if (files.length > maxFiles) {
print(
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements',
);
files.sort(
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
); // Du plus vieux au plus récent
/// Fichiers sur disque associés à un enregistrement : fichier principal,
/// log, et parties issues des relances.
List<String> filesFor(String filePath) {
final paths = <String>[filePath, p.setExtension(filePath, '.log')];
for (var attempt = 1;; attempt++) {
final part = _partPath(filePath, attempt);
if (!File(part).existsSync()) break;
paths.add(part);
}
return paths;
}
// Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles);
for (var file in filesToDelete) {
file.deleteSync();
/// Supprime les fichiers d'un enregistrement (appelé par l'API à la
/// suppression, et par la rotation disque).
Future<void> deleteRecordingFiles(String filePath) async {
for (final path in filesFor(filePath)) {
await _deleteQuietly(path);
}
}
/// Rotation par quota d'octets (env RECORDINGS_QUOTA_GB, 0 = désactivée).
///
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait les plus
/// anciens fichiers du dossier sans distinction : elle pouvait effacer une
/// partie en cours d'écriture par FFmpeg et laissait en base des lignes
/// pointant vers des fichiers disparus. Ici on ne supprime que des
/// enregistrements TERMINÉS connus de la base, du plus ancien au plus
/// récent, fichiers et ligne BDD ensemble, jamais un enregistrement actif.
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
if (recordingsQuotaGb <= 0) return;
try {
final quotaBytes = recordingsQuotaGb * 1024 * 1024 * 1024;
var totalBytes = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
totalBytes += await entity.length();
} catch (_) {}
}
}
if (totalBytes <= quotaBytes) return;
print(
'[RecordingScheduler] Quota disque dépassé '
'(${totalBytes ~/ (1024 * 1024)} Mo > $recordingsQuotaGb Go) : '
'rotation des enregistrements terminés les plus anciens',
);
for (final old in _db.getFinishedRecordingsOldestFirst()) {
if (totalBytes <= quotaBytes) break;
final path = old.filePath;
if (path == null) continue;
// Jamais un enregistrement encore capturé (statut périmé en base).
if (_active.containsKey(old.id)) continue;
var freed = 0;
for (final f in filesFor(path)) {
try {
freed += File(f).existsSync() ? File(f).lengthSync() : 0;
} catch (_) {}
}
await deleteRecordingFiles(path);
_db.deleteRecording(old.id);
totalBytes -= freed;
print(
'[RecordingScheduler] Rotation : "${old.title}" supprimé '
'(${freed ~/ (1024 * 1024)} Mo libérés)',
);
}
} catch (e) {
print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
+4 -1
View File
@@ -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:-}
File renamed without changes.
+12
View File
@@ -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`.
+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'authed_http.dart';
/// Point d'entrée unique pour créer un enregistrement via POST /api/recordings.
///
/// Le backend exige désormais des dates ISO-8601 AVEC fuseau (suffixe 'Z' ou
/// offset) et rejette les dates naïves en 400 : trois conventions d'envoi
/// coexistaient dans l'app (UTC, local naïf, UTC naïf), d'où des
/// enregistrements décalés de 1-2 h selon l'écran utilisé.
///
/// [wallClockIsUtc] : les horaires issus de l'EPG sont des heures UTC
/// « naïves » (parsées sans fuseau par Dart mais comparées à `now.toUtc()`
/// partout dans l'app). true les re-tague en UTC sans décalage ; false (par
/// défaut) convertit depuis l'heure locale réelle (cas d'un DateTime.now()).
Future<http.Response> postRecording({
required String channelId,
required String title,
required DateTime start,
required DateTime end,
String? streamUrl,
bool wallClockIsUtc = false,
}) {
DateTime asUtc(DateTime d) {
if (d.isUtc) return d;
return wallClockIsUtc
? DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second)
: d.toUtc();
}
return AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': channelId,
'stream_url': streamUrl ?? '/api/live/$channelId.ts',
'title': title,
'start_time': asUtc(start).toIso8601String(),
'end_time': asUtc(end).toIso8601String(),
}),
);
}
@@ -0,0 +1,14 @@
import 'package:flutter/foundation.dart';
/// Bus de notification global pour la liste des enregistrements.
///
/// L'onglet Enregistrements vit dans un IndexedStack : il n'est construit
/// qu'une seule fois au lancement. Tout code qui crée/modifie un
/// enregistrement (modal d'enregistrement, guide EPG, ...) appelle
/// [notifyRecordingsChanged] pour que la liste se recharge immédiatement,
/// sans attendre le prochain cycle de polling.
final ValueNotifier<int> recordingsRefreshBus = ValueNotifier<int>(0);
void notifyRecordingsChanged() {
recordingsRefreshBus.value++;
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Spacer(),
// Settings / Profile
// Profil : nom d'utilisateur + déconnexion. L'avatar était
// un bouton mort (onTap vide) — aucune déconnexion possible
// depuis le dashboard desktop.
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: TvFocusableCard(
onTap: () {},
borderRadius: 50,
scaleFactor: 1.1,
child: PopupMenuButton<String>(
tooltip: 'Profil',
color: AppColors.surfaceContainerHigh,
offset: const Offset(56, -12),
itemBuilder: (context) => [
PopupMenuItem<String>(
enabled: false,
child: Row(
children: [
const Icon(
Icons.person,
size: 18,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Text(
ref.read(authProvider).currentUser?.username ??
'Utilisateur',
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(
Icons.logout,
size: 18,
color: AppColors.textSecondary,
),
SizedBox(width: 8),
Text(
'Déconnexion',
style: TextStyle(color: AppColors.onSurface),
),
],
),
),
],
onSelected: (value) {
if (value == 'logout') {
ref.read(authProvider.notifier).logout();
}
},
child: const CircleAvatar(
radius: 20,
backgroundColor: AppColors.surfaceContainerHigh,
+8 -7
View File
@@ -190,18 +190,17 @@ 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';
@@ -271,8 +270,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
});
}
// Update watch history if relevant
if (currentTime > 0) {
// 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,
currentTime,
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../../../core/theme/app_colors.dart';
import 'player_screen.dart';
@@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}
if (!context.mounted) return;
// Reprise de lecture : le player sauvegarde la position sous
// episode.id, on la relit ici pour reprendre où on s'était arrêté.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(episode.id)
? positions.getPosition(episode.id)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episodeDuration,
startTime: resumeAt,
),
),
);
@@ -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)
+54 -2
View File
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
body: channelsAsync.when(
loading: () => const ThemedLoading(),
error: (e, s) => Center(
child: Text('Error: $e',
style: const TextStyle(color: AppColors.onSurface)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les chaînes',
style: TextStyle(color: AppColors.onSurface),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: () => ref.invalidate(
liveChannelsByPlaylistProvider(widget.playlist),
),
),
],
),
),
data: (groupedChannels) {
var categories = groupedChannels.keys.toList();
@@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
],
),
// Favori (Positioned top left) — toggleFavorite n'était appelé
// nulle part : le filtre « Favoris » affichait toujours vide.
Positioned(
top: 8,
left: 8,
child: Consumer(
builder: (context, ref, _) {
final isFav = ref
.watch(favoritesProvider)
.contains(channel.streamId);
return TvFocusableCard(
onTap: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
borderRadius: 20,
scaleFactor: 1.2,
semanticLabel: isFav
? 'Retirer ${channel.name} des favoris'
: 'Ajouter ${channel.name} aux favoris',
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav
? AppColors.primary
: AppColors.onSurface54,
size: 16,
),
),
);
},
),
),
// Record Button Icon Overlay (Positioned top right)
Positioned(
top: 8,
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/hero_carousel.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
if (!mounted) return;
// Reprise de lecture : les positions étaient sauvegardées par le player
// mais jamais relues — le film repartait systématiquement de zéro.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(movie.streamId)
? positions.getPosition(movie.streamId)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
duration: movieDuration,
startTime: resumeAt,
),
),
);
+11 -15
View File
@@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/api/recording_requests.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../providers/recordings_refresh.dart';
class RecordingModal extends StatefulWidget {
final Channel channel;
@@ -42,23 +42,19 @@ class _RecordingModalState extends State<RecordingModal> {
setState(() => _isLoading = true);
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
try {
// Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes)
final response = await AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': widget.channel.streamId,
'stream_url': '/api/live/${widget.channel.streamId}.ts',
'title': widget.channel.name,
// Forcer UTC pour éviter le décalage +01:00 (France) vs UTC (serveur Docker)
'start_time': _startTime.toUtc().toIso8601String(),
'end_time': endTime.toUtc().toIso8601String(),
}),
// _startTime est une vraie heure locale (pickers) : postRecording la
// convertit en UTC — seule convention acceptée par le backend.
final response = await postRecording(
channelId: widget.channel.streamId,
title: widget.channel.name,
start: _startTime,
end: endTime,
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enregistrement programmé !')),
+241 -41
View File
@@ -1,17 +1,24 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
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/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../providers/recordings_refresh.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
// ═══════════════════════════════════════════════════════════════════════════
// ENTRÉE — Onglet "Enregistrements"
// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste),
// Season Passes (enregistrements récurrents). _EpgGuideView et
// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis
// une refonte : les fonctions étaient codées mais inaccessibles.
// ═══════════════════════════════════════════════════════════════════════════
class RecordingsTab extends StatefulWidget {
@@ -22,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
State<RecordingsTab> createState() => _RecordingsTabState();
}
class _RecordingsTabState extends State<RecordingsTab> {
class _RecordingsTabState extends State<RecordingsTab>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
// Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le
// plus fréquent ; le guide sert à en programmer de nouveaux.
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
@@ -30,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
child: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
color: Colors.grey[900],
child: const Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12),
Text(
'Enregistrements',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
const Row(
children: [
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12),
Text(
'Enregistrements',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
),
],
),
const SizedBox(height: 8),
TabBar(
controller: _tabController,
isScrollable: true,
indicatorColor: AppColors.primary,
labelColor: AppColors.onSurface,
unselectedLabelColor: AppColors.onSurface54,
tabs: const [
Tab(
icon: Icon(Icons.calendar_month, size: 18),
text: 'Guide TV',
),
Tab(
icon: Icon(Icons.fiber_manual_record, size: 18),
text: 'Enregistrements',
),
Tab(
icon: Icon(Icons.repeat, size: 18),
text: 'Season Passes',
),
],
),
],
),
),
Expanded(
child: _RecordingsListView(playlist: widget.playlist),
child: TabBarView(
controller: _tabController,
children: [
_EpgGuideView(playlist: widget.playlist),
_RecordingsListView(playlist: widget.playlist),
const _SeasonPassesView(),
],
),
),
],
),
@@ -592,17 +650,17 @@ class _ProgrammeCard extends StatelessWidget {
DateTime end,
) async {
try {
final response = await AuthedHttp.post(
Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'channel_id': channel.streamId,
'stream_url': '/api/live/${channel.streamId}.ts',
'title': title,
'start_time': start.toIso8601String(),
'end_time': end.toIso8601String(),
}),
// Les horaires EPG sont des heures UTC naïves : wallClockIsUtc les
// re-tague sans décalage (l'ancien envoi naïf était interprété dans le
// fuseau du serveur → enregistrement décalé de 1-2 h).
final response = await postRecording(
channelId: channel.streamId,
title: title,
start: start,
end: end,
wallClockIsUtc: true,
);
if (response.statusCode == 200) notifyRecordingsChanged();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -733,37 +791,83 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
List<dynamic> _recordings = [];
bool _isLoading = true;
String? _error;
Timer? _pollTimer;
DateTime? _lastRefresh;
// Polling rapproché quand un enregistrement est en cours, plus espacé sinon.
static const _activeInterval = Duration(seconds: 5);
static const _idleInterval = Duration(seconds: 20);
@override
void initState() {
super.initState();
recordingsRefreshBus.addListener(_onExternalChange);
_fetchRecordings();
}
Future<void> _fetchRecordings() async {
setState(() {
_isLoading = true;
_error = null;
});
@override
void dispose() {
recordingsRefreshBus.removeListener(_onExternalChange);
_pollTimer?.cancel();
super.dispose();
}
/// Un enregistrement vient d'être créé/modifié ailleurs dans l'app.
void _onExternalChange() {
_fetchRecordings(silent: true);
}
bool get _hasActiveOrPending => _recordings.any((rec) {
final status = rec is Map ? rec['status'] as String? : null;
return status == 'recording' || status == 'scheduled';
});
void _scheduleNextPoll() {
_pollTimer?.cancel();
_pollTimer = Timer(
_hasActiveOrPending ? _activeInterval : _idleInterval,
() => _fetchRecordings(silent: true),
);
}
/// [silent] : recharge en arrière-plan sans spinner ni vidage de la liste,
/// pour que le suivi automatique ne fasse pas clignoter l'écran.
Future<void> _fetchRecordings({bool silent = false}) async {
if (!silent) {
setState(() {
_isLoading = true;
_error = null;
});
}
try {
final response = await AuthedHttp.get(Uri.parse('/api/recordings'));
if (!mounted) return;
if (response.statusCode == 200) {
setState(() {
final decoded = json.decode(response.body);
_recordings = decoded is List ? decoded : [];
_isLoading = false;
_error = null;
_lastRefresh = DateTime.now();
});
} else {
} else if (!silent) {
setState(() {
_error = 'Erreur ${response.statusCode}';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = '$e';
_isLoading = false;
});
if (!mounted) return;
// En mode silencieux on garde la liste affichée plutôt que
// de remplacer l'écran par une erreur transitoire.
if (!silent) {
setState(() {
_error = '$e';
_isLoading = false;
});
}
} finally {
if (mounted) _scheduleNextPoll();
}
}
@@ -776,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
}
}
Future<void> _deleteRecording(String id) async {
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
/// immédiatement, sans retour ni possibilité d'annuler.
Future<void> _deleteRecording(String id, String title) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainer,
title: Text(
'Supprimer l\'enregistrement ?',
style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18),
),
content: Text(
'« $title » et son fichier seront définitivement supprimés.',
style: const TextStyle(color: AppColors.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.errorContainer,
foregroundColor: AppColors.onErrorContainer,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true) return;
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
_fetchRecordings();
}
@@ -865,6 +1001,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => AppColors.live,
'completed' => AppColors.success,
'failed' => AppColors.warning,
'cancelled' => AppColors.onSurface38,
_ => AppColors.primaryContainer,
};
@@ -873,9 +1010,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => '● En cours',
'completed' => 'Terminé',
'failed' => 'Échoué',
'cancelled' => 'Annulé',
_ => status,
};
String _fmtSize(int bytes) {
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go';
}
if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo';
}
return '${(bytes / 1024).toStringAsFixed(0)} Ko';
}
String _fmtDate(dynamic raw) {
if (raw == null) return '?';
try {
@@ -896,10 +1044,27 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(
Icons.autorenew,
color: _hasActiveOrPending
? AppColors.success
: AppColors.onSurface38,
size: 14,
),
const SizedBox(width: 6),
Text(
_lastRefresh == null
? 'Suivi automatique'
: 'Suivi auto · ${_lastRefresh!.hour.toString().padLeft(2, '0')}:${_lastRefresh!.minute.toString().padLeft(2, '0')}:${_lastRefresh!.second.toString().padLeft(2, '0')}',
style: const TextStyle(
color: AppColors.onSurface38,
fontSize: 11,
),
),
IconButton(
icon: const Icon(Icons.refresh, color: AppColors.onSurfaceVariant),
onPressed: _fetchRecordings,
tooltip: 'Rafraîchir',
tooltip: 'Rafraîchir maintenant',
),
],
),
@@ -908,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error ?? 'Erreur',
style: const TextStyle(color: AppColors.live),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les enregistrements',
style: TextStyle(color: AppColors.live),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: _fetchRecordings,
),
],
),
)
: _recordings.isEmpty
@@ -974,12 +1150,34 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
CrossAxisAlignment.start,
children: [
Text(
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}',
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}'
'${rec['file_size_bytes'] is int ? ' · ${_fmtSize(rec['file_size_bytes'] as int)}' : ''}',
style: const TextStyle(
color: AppColors.onSurface54,
fontSize: 12,
),
),
if (status == 'recording' &&
rec['progress_pct'] is int) ...[
const SizedBox(height: 6),
ClipRRect(
borderRadius:
BorderRadius.circular(3),
child: LinearProgressIndicator(
value:
(rec['progress_pct'] as int) /
100,
minHeight: 4,
backgroundColor: AppColors
.onSurface
.withOpacity(0.1),
valueColor:
const AlwaysStoppedAnimation(
AppColors.live,
),
),
),
],
if (rec['error_reason'] != null)
Text(
'⚠ ${rec['error_reason']}',
@@ -1057,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
size: 20,
),
tooltip: 'Supprimer',
onPressed: () =>
_deleteRecording(rec['id']),
onPressed: () => _deleteRecording(
rec['id'],
rec['title'] ?? '',
),
),
],
),
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
import '../providers/recordings_refresh.dart';
class SimpleRecordingWidget extends StatefulWidget {
final Channel channel;
@@ -43,6 +44,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
setState(() {
_isRecording = true;
_status = '🔴 Recording for $minutes min';
@@ -79,6 +81,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
);
if (response.statusCode == 200) {
notifyRecordingsChanged();
setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}');
if (mounted) {
@@ -101,6 +104,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
headers: {'Content-Type': 'application/json'},
);
notifyRecordingsChanged();
setState(() {
_isRecording = false;
_status = 'Stopped';
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../widgets/mobile_scaffold.dart';
import '../../../theme/mobile_theme.dart';
import '../../../../features/iptv/widgets/recordings_tab.dart';
import '../widgets/mobile_live_tv_tab.dart';
import '../widgets/mobile_movies_tab.dart';
import '../widgets/mobile_series_tab.dart';
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
_currentIndex = index;
});
},
child: _buildActiveTab(),
// IndexedStack conserve l'état des onglets (position de scroll,
// catalogues chargés) : l'ancien switch reconstruisait tout à chaque
// changement d'onglet. Même approche que le dashboard desktop.
child: IndexedStack(
index: _currentIndex,
children: [
MobileLiveTVTab(playlist: widget.playlist),
MobileMoviesTab(playlist: widget.playlist),
MobileSeriesTab(playlist: widget.playlist),
RecordingsTab(playlist: widget.playlist),
const MobileSettingsTab(),
],
),
),
);
}
Widget _buildActiveTab() {
switch (_currentIndex) {
case 0:
return MobileLiveTVTab(playlist: widget.playlist);
case 1:
return MobileMoviesTab(playlist: widget.playlist);
case 2:
return MobileSeriesTab(playlist: widget.playlist);
case 3:
return const MobileSettingsTab();
default:
return MobileLiveTVTab(playlist: widget.playlist);
}
}
}
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends StatelessWidget {
class _MobileChannelTile extends ConsumerWidget {
final Channel channel;
final VoidCallback onTap;
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// 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}'
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris',
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? AppColors.primary : AppColors.onSurface54,
size: 20,
),
onPressed: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+5
View File
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.videocam_outlined),
activeIcon: Icon(Icons.videocam_rounded),
label: 'REC',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
+1266
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -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
+2 -8
View File
@@ -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
+20 -6
View File
@@ -131,7 +131,10 @@
};
XFPlayer.prototype.send = function (msg) {
try { global.parent.postMessage(msg, '*'); } catch (e) {}
// Cible restreinte à notre origine : l'iframe est toujours même-origine
// que le parent Flutter, un wildcard '*' livrerait l'état du player à
// n'importe quelle page qui embarquerait player.html.
try { global.parent.postMessage(msg, global.location.origin); } catch (e) {}
};
// ---------------- Démarrage ----------------
@@ -281,7 +284,13 @@
// 64 Ko en profil « fast » contre 512 Ko auparavant.
stashInitialSize: this.profile.mpegtsStash,
maxStashSize: 30 * 1024 * 1024,
liveBufferLatencyChasing: false,
// Rattrapage du direct : sans lui, chaque micro-coupure fait dériver
// la lecture derrière le direct (latence qui s'accumule). Activé
// uniquement en profil « fast » — les profils élargis privilégient
// la stabilité du buffer.
liveBufferLatencyChasing: isLive && this.profile.name === 'fast',
liveBufferLatencyMaxLatency: 5,
liveBufferLatencyMinRemain: 1,
lazyLoad: false
}
);
@@ -338,13 +347,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';
};
@@ -491,6 +502,9 @@
XFPlayer.prototype._wireParentMessages = function () {
var self = this;
global.addEventListener('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;
var d = event.data;
if (!d || !d.type) return;
var v = self.video;