Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 530bf82b04 CI : installer rustfmt pour cargo fmt --check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:32:20 +02:00
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00
MichaelandClaude Opus 5.5 f82c70ae05 Lecture TV : reconstituer la réserve en lisant à x0,97
Télémétrie (session olfp14) : le panneau n'avait envoyé que ~18 s
d'avance au démarrage ; la réserve est restée à 13-20 s et un silence du
panneau a fini en coupure de 5,5 s. Le lecteur ne fabriquait pas de
réserve : il gardait seulement celle offerte par le panneau.

Régulateur de vitesse unique pour le direct MPEG-TS, chaque seconde :
sous 20 s d'avance, x0,97 (hauteur du son préservée) jusqu'à 25 s ;
au-delà de 35 s (rafale de rattrapage), x1,1 jusqu'à 28 s. Seuils
d'entrée/sortie distincts : pas d'oscillation. liveSync de mpegts.js
désactivé : il ne sait qu'accélérer et remettait la vitesse à 1 sous sa
cible, ce qui annulerait le ralenti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:11:59 +02:00
MichaelandClaude Opus 5.5 6733f80ffd Lecture TV : plus de saut de 20 s après un silence du panneau
Télémétrie (session de 25 min avec la réserve de 25 s) : un silence du
panneau a été absorbé sans coupure (avance 27 → 14,7 s), mais à la
reprise le panneau renvoie d'un coup tout le retard. L'avance dépassait
alors le seuil de rattrapage (45 s) et le lecteur sautait 20 s de
programme — deux fois en une minute.

Saut seulement au-delà de 90 s de retard ; liveSync (x1,1 au-delà de
35 s) résorbe l'excédent en douceur. Vidéo déjà vue purgée plus tôt
(30 s) pour rester sous le quota du SourceBuffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:41:43 +02:00
MichaelandClaude Opus 5.5 0539ad857f Lecture TV : traverser les silences du panneau sans figer
Télémétrie d'un freeze en prod : le panneau s'est tu ~15-20 s sur une
connexion restée ouverte (aucune reconnexion FFmpeg dans les logs).
Le lecteur n'avait gardé que 11-16 s d'avance — il « consommait » à x1,1
celle que le panneau envoie au démarrage pour revenir à 12 s — puis,
après la coupure de 6,3 s, il repartait avec 1 s de stock et recoupait
aussitôt (1,7 s puis 2,0 s).

- réserve visée 25 s (liveSync 25/35, saut au-delà de 45 s) : ~25 s de
  retard sur le direct en échange de la traversée de ces silences
- après une coupure, attente de 4 s de réserve avant de relire (comme
  mpv cache-pause), 15 s au plus ; une pause utilisateur l'annule ; le
  lecteur lite affiche « Reconstitution de la réserve » au lieu du
  bouton lecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:04:25 +02:00
MichaelandClaude Opus 5.5 a739d6451c Diagnostic : le lecteur remonte ses freezes dans les logs du serveur
Les freezes (image et son figés puis reprise) se produisent dans le
navigateur de l'utilisateur ; le serveur n'en voyait rien. Le lecteur
envoie désormais chaque coupure (durée, buffer), saut (de → vers),
erreur MPEG-TS/HLS, recréation, repli HLS et changement de profil, plus
un bilan toutes les 60 s, à POST /api/player-log (authentifié). Le
serveur écrit une ligne « [Player] … » par événement.

Entrée filtrée : événements et champs connus seulement, texte borné,
URL masquées (une erreur réseau peut recopier les identifiants Xtream),
pas d'injection de lignes. Envoi jamais bloquant pour la lecture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:38:17 +02:00
MichaelandClaude Opus 5.5 775caad0ee Annule « Zap : ouvrir les chaînes par le HLS du panneau »
Mesure après déploiement : premier octet de turbo.ts à 4,5–4,8 s avec la
source HLS, contre 4,3–4,7 s en .ts — aucun gain. Les logs confirment
que le HLS était bien utilisé (aucun repli). La sonde qui annonçait
0,5 s ouvrait le .ts AVANT le .m3u8 : la chaîne était déjà démarrée chez
le panneau. Les ~4,5 s sont le temps de démarrage d'une chaîne chez le
fournisseur, quel que soit le format.

Retour au .ts, déjà validé en lecture, plutôt que de garder un chemin
plus complexe (segments de 10 s, repli) sans bénéfice mesuré.

This reverts commit 0ea60cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:51 +02:00
MichaelandClaude Opus 5.5 4d00409de8 Guide TV : régionales France 3, variantes de nom, chaînes +1, CH et BE
Mesuré en prod après le premier correctif : 69 chaînes sur 149 avec
guide. Les vides restantes relevaient surtout de formes de nom que le
dump écrit autrement, ou de pays non couverts :

- « F3 ALPES » → France 3 Alpes (France.3.-.Alpes.fr) : F2 à F5 développés
- clé souple en dernier recours, dans son propre espace de noms :
  sans code pays ni « channel/tv/hd » (« AB3 » → AB3.Channel.fr)
- chaîne « +1 » absente du dump (TF1 +1) : guide de la chaîne principale
  décalé d'une heure ; une +1 qui a son propre guide le garde
- sources par défaut : FR1 puis CH1 (RTS…) et BE2 (même fournisseur),
  le dump français reste prioritaire

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:17:28 +02:00
MichaelandClaude Opus 5.5 0ea60ccbfa Zap : ouvrir les chaînes par le HLS du panneau, ~4 s gagnées
Mesuré en prod avec la sonde admin : le panneau met 4,2 à 4,4 s à
répondre la redirection d'un .ts, contre 0,2 à 1,3 s pour un .m3u8 dont
le premier segment arrive ensuite en 50 ms.

turbo.ts et les sessions HLS live lisent désormais le .m3u8 du panneau
(-live_start_index -2 : deux segments d'avance d'un coup), avec repli
automatique sur le .ts si le HLS ne livre rien — dans la même réponse
pour turbo.ts, le lecteur ne voit qu'un démarrage plus long. Pas de
reconnect_at_eof en HLS : chaque segment finit par un EOF (leçon de
xtremobile). Le navigateur reçoit toujours le même flux MPEG-TS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:08:08 +02:00
MichaelandClaude Opus 5.5 a3640a6b5f Diagnostic admin : chronométrer l'ouverture d'une chaîne chez le panneau
~4,3 s des ~4,6 s d'un zap se passent avant le premier octet du panneau.
Le compte autorise aussi le HLS (allowed_output_formats : m3u8, ts) ;
GET /api/admin/upstream-probe?stream=<id> mesure, pour .ts et .m3u8,
chaque redirection, les en-têtes, le premier octet et le premier segment,
pour décider du format à utiliser. Admin uniquement ; seuls l'hôte et les
durées sont rendus, jamais les identifiants ni les jetons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:56:34 +02:00
MichaelandClaude Opus 5.5 584068b45b Guide TV : décoder la table des chaînes en UTF-8
Sans charset annoncé, Response.body pouvait décoder le JSON du panneau en
latin-1 et abîmer les noms accentués ou décorés (« Chérie », « ◉ »), donc
leur correspondance avec le guide XMLTV. Le test simule désormais des
octets UTF-8 sans charset, comme le panneau réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:54:46 +02:00
MichaelandClaude Opus 5.5 abe7d522c1 Guide TV : plus jamais de 500, et bien plus de chaînes couvertes
Constaté en prod : chaque /api/epg répondait 500 en 4 s. Un appel
player_api qui lève (connexion coupée, délai) n'était pas rattrapé, et le
dump XMLTV externe, qui couvre pourtant TF1, France 2, M6…, n'était jamais
consulté. Et 59 % des chaînes françaises n'ont pas d'epg_channel_id : leur
nom brut (« FR - TF1 FHD ◉ ») ne correspondait à aucune entrée XMLTV.

- échec player_api rattrapé : la chaîne passe à la source suivante
- dump externe (déjà en mémoire) avant l'appel lent chaîne par chaîne
- correspondance par nom nettoyé : préfixe pays, FHD/HD/UHD/4K/HEVC…,
  symboles retirés, forme « nom.pays » tentée ; « +1 » conservé
- tous les display-name XMLTV indexés, plus seulement le premier
- table des chaînes : un seul téléchargement partagé, décodé hors de la
  boucle principale, et un échec n'est plus gardé 6 h (2 min)
- guide vide gardé 5 min au lieu de 30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:51:24 +02:00
MichaelandClaude Opus 5.5 8d79472ca8 Démarrage : la lecture du quota ne fait plus attendre le flux
AccountLimits interrogeait player_api.php avant d'ouvrir le flux quand
sa valeur n'était pas en cache (premier flux, puis toutes les 10 min).
Le panneau met jusqu'à 4 s à répondre (mesuré en prod) : autant de
retard au démarrage d'une chaîne ou d'un film.

La valeur connue, même périmée, ou le repli (1) est rendue tout de suite ;
la lecture chez le panneau se fait en arrière-plan, une seule à la fois.
Sans risque : le quota ne sert qu'à choisir quels flux orphelins couper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:34:26 +02:00
MichaelandClaude Opus 5.5 6812a4964d Lecture : ne couper que les flux orphelins, jamais un flux regardé
La règle « le dernier flux gagne » coupait aussi un flux en cours de
lecture. Deux lecteurs ouverts sur un compte à une connexion se coupaient
alors en boucle (chacun relance aussitôt) : plus aucune image nulle part.
Constaté en prod : tant qu'un autre onglet lisait, chaque ouverture
échouait après 5 s sans un octet.

Une connexion n'est désormais coupée que si son spectateur ne donne plus
signe de vie depuis 15 s : aucune requête de playlist/segment (HLS, film)
ni octet remis au client (turbo.ts). Les orphelins partent toujours, un
second lecteur actif est refusé par le panneau comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:13:58 +02:00
MichaelandClaude Opus 5.5 fdf98f9659 Films : demander le fichier sous sa vraie extension
Le serveur réclamait toujours <id>.mkv au panneau. Un film stocké en .mp4
était refusé (HTTP 551 constaté en prod). Le client transmet désormais
container_extension (?ext=), validé côté serveur ; mkv reste le défaut
pour un client qui ne l'envoie pas. Même approche que xtremobile, qui lit
ces films sans souci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:09:11 +02:00
MichaelandClaude Opus 5.5 6212948ea5 Déploiement : versionner les JS pour contourner le cache du proxy
main.dart.js, flutter_bootstrap.js et xf-player-core.js gardent le même
nom d'une build à l'autre. En prod, Nginx Proxy Manager les met en cache
(Cache Assets) jusqu'à 12 h, même sur rechargement forcé : après le
dernier déploiement, l'ancienne app et l'ancien lecteur tournaient avec
le nouveau serveur.

Au démarrage, le serveur réécrit index.html, flutter_bootstrap.js et
player*.html pour référencer ces fichiers avec ?v=<empreinte du contenu> :
chaque version a sa propre URL, aucun cache ne peut les confondre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:09:11 +02:00
MichaelandClaude Opus 5.5 662e1034e5 Lecture : couper le flux orphelin avant d'en ouvrir un nouveau
L'abonnement n'autorise qu'une connexion simultanée (max_connections = 1,
vérifié en prod). Or un FFmpeg de lecture survivait au spectateur : 4 min
pour un direct HLS, jusqu'à 15 min ou la fin du téléchargement pour un
film, le temps de détecter la déconnexion pour turbo.ts. Le flux suivant
trouvait le slot pris : HTTP 551 ou timeout côté panneau (film qui ne
démarre pas, zap qui échoue).

Avant d'ouvrir une connexion amont, le serveur coupe désormais les plus
anciennes connexions de lecture du même compte, dans la limite du quota
max_connections lu chez le fournisseur (cache 10 min, 1 par défaut). Les
enregistrements ne sont jamais coupés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:51:56 +02:00
MichaelandClaude Opus 5.5 a9b43ff581 Lecture TV : fin des coupures en rafale sur le direct
Les panneaux Xtream livrent le flux par rafales (~6 s de contenu puis
plusieurs secondes de silence). Le rattrapage mpegts.js réglé à 5 s max /
1 s de marge sautait en avant à chaque rafale puis tombait à sec : mesuré
en prod, 12 sauts et 14 coupures en 30 s sur une chaîne FHD.

Marge portée à 12 s, saut seulement au-delà de 30 s de retard, retard
résorbé par liveSync (x1,1) et purge du buffer déjà lu. Rejoué en prod
avec cette config : 0 coupure en continu.

Ajoute des tests Node du moteur web, lancés par la CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:37:55 +02:00
MichaelandClaude Opus 5.5 327873d254 Journaux : masquer les identifiants Xtream des URL et exceptions
Le repli XMLTV journalisait l'URL `xmltv.php?username=…&password=…` du
panneau en clair, et le texte des `ClientException` / `ProcessException`
recopiait aussi l'URL : les identifiants de l'abonné finissaient dans
`docker logs`. Passage par `LogRedactor.redactUrl` dans XmltvEpgService,
EpgApi, le relais live, FFmpegSessionManager et RecordingScheduler.

Test : capture des `print` via une Zone pour vérifier le masquage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:25:00 +02:00
MichaelandClaude Opus 5.5 da67c479b3 Guide TV : indexer le XMLTV hors de la boucle principale
Le serveur n'a qu'une boucle d'événements. La décompression et le
parsing des dumps XMLTV (7000+ chaînes, ~90 Mo) la gelaient pendant
plusieurs secondes : plus rien ne répondait, y compris le relais
turbo.ts. La lecture se coupait donc au démarrage, au moment où
l'écran des chaînes demandait le guide (EPG à 19-26 s dans les logs).

- Téléchargement inchangé, puis gunzip + utf8 + parse dans Isolate.run
- _parse devient statique (aucune capture de this, client HTTP non
  transférable)
- Test : la boucle d'événements ne doit pas geler plus de 250 ms
  pendant l'indexation d'un dump de taille réelle (1,65 s avant)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:54 +02:00
LogiFlow 9f4e98fcea Merge pull request #19 from R0m1k3/chore/lockfiles-3.47
pubspec.lock régénéré avec Flutter 3.47.6
2026-10-08 22:12:55 +02:00
MichaelandClaude Opus 5.5 117523f153 pubspec.lock régénéré avec Flutter 3.47.6
La montée en Dart 3.13 / Flutter 3.47.6 n'avait ajusté que la section
`sdks` à la main. `flutter pub get` réaligne les paquets que le SDK
épingle via flutter / flutter_test : characters 1.4.1, matcher 0.12.20,
material_color_utilities 0.13.0, meta 1.19.0, test_api 0.7.12,
vector_math 2.4.3. bin/pubspec.lock était déjà cohérent.

Vérifié en local (Flutter 3.47.6 / Dart 3.13.5) : dart analyze et
dart test côté serveur (94 tests), dart analyze lib test (infos
seulement) et flutter test côté front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:55:18 +02:00
LogiFlow 08d841180f Merge pull request #18 from R0m1k3/chore/dart-3.13
Dart 3.13 / Flutter 3.47.6 : SDK à jour et épinglé partout
2026-10-08 21:35:32 +02:00
LogiFlow 73469060ff Merge pull request #17 from R0m1k3/fix/repli-gpu-cpu
Lecture : repli CPU quand le GPU sature, plus de spinner infini
2026-10-08 21:35:20 +02:00
MichaelandClaude Opus 5.5 e19afd00f1 Dart 3.13 / Flutter 3.47.6 : SDK à jour et épinglé partout
L'image ghcr.io/cirruslabs/flutter:stable n'est plus publiée depuis
Flutter 3.44.0 : le build Docker compilait toujours avec Dart 3.12.0
(mai 2026), alors que la CI testait le serveur en Dart 3.13 et le
front en Flutter 3.38.4. Trois SDK différents selon l'endroit.

- Dockerfile : SDK Flutter officiel 3.47.6 (Dart 3.13.5) téléchargé
  depuis flutter_infra_release, version en ARG
- CI : Flutter 3.38.4 → 3.47.6, Dart 3.13.2 → 3.13.5 — même SDK que
  l'image
- pubspec : contrainte sdk ^3.13.0 (au lieu de >=3.0.0) pour pouvoir
  utiliser les nouveautés du langage (constructeurs primaires,
  raccourcis `.valeur`, éléments `?x`). Les lockfiles exigeaient déjà
  3.10.3 / 3.11.0 : le minimum affiché était faux

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:30:29 +02:00
MichaelandClaude Opus 5.5 db13c85c1d Lecture : repli CPU quand le GPU sature, plus de spinner infini
Serveur chargé ou VRAM limite : NVENC refuse d'ouvrir l'encodeur
(OpenEncodeSessionEx failed: out of memory), FFmpeg meurt en une
seconde et la playlist part en 502. Chaque relance retombait sur le
même GPU saturé : le flux restait illisible.

- Serveur : un échec imputable au GPU (NVENC, CUDA, mémoire) relance
  la session une fois en libx264, sous le même identifiant — les
  segments suivent sans que le lecteur s'en aperçoive. Direct, VOD et
  enregistrements
- Le GPU est écarté 2 min après une panne : les sessions suivantes
  partent directement sur le CPU au lieu d'échouer une à une
- Source injoignable ou délai dépassé : pas de repli, il doublerait la
  charge d'un serveur déjà occupé
- Process.start qui échoue (serveur à court de ressources) : 502/503
  au lieu d'une exception remontée en 500
- Client : une erreur réseau fatale avant la première playlist
  appelait hls.startLoad(), qui ne relance pas ce chargement → spinner
  infini, aucune erreur, relances de page jamais déclenchées. L'erreur
  remonte désormais à la page
- mpegts.js : au-delà de 3 erreurs en 60 s, bascule sur HLS au lieu de
  recréer sans fin (un FFmpeg neuf toutes les 1,2 s)
- player_lite.html : 3 relances avant l'erreur, comme les autres pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:28:26 +02:00
LogiFlow 6d40c38d30 Merge pull request #16 from R0m1k3/fix/logos-repli
Logos des chaînes : repli par nom quand l'hébergeur de picons tombe
2026-09-19 13:42:50 +02:00
MichaelandClaude Opus 5 9843999809 Logos des chaînes : repli par nom quand l'hébergeur de picons tombe
L'hébergeur des picons du fournisseur (51.158.145.100) répond toujours
503 sur toutes ses URL. Couper le flot de requêtes ne suffisait pas : la
grille restait sans logo. Pire, le pixel transparent servi en 200 était
pris pour une image valide par Flutter, qui n'affichait plus son icône
de repli — d'où des tuiles entièrement vides.

- Nouvelle route `/api/logo?src=…&name=…` : URL du panneau d'abord (via
  le proxy, mêmes protections anti-SSRF et même mémoire des hôtes
  morts), puis recherche par nom dans le dépôt public tv-logo/tv-logos
- Appariement par nom : préfixe pays et suffixes de qualité ignorés,
  `+` → `plus`, variante plus longue (`bein-sports-1-french`), puis
  marque sans numéro pour les canaux événementiels. Aucune approximation
  sur un mot seul
- Liste de logos en cache 24 h (API GitHub : 60 requêtes/h non
  authentifiées), 15 min de recul après échec, images en mémoire
- Image indisponible : 410 mis en cache au lieu du pixel. 410 et non
  404 : la Cascade du serveur rattrape les 404 et renvoyait celui du
  handler statique, sans cache-control
- Client (grille bureau, mobile, enregistrements) : logo toujours
  demandé au backend, nom compris, même sans stream_icon. L'onglet
  Enregistrements chargeait l'URL http brute, bloquée en HTTPS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:39:01 +02:00
LogiFlow bde32adda9 Merge pull request #15 from R0m1k3/fix/picons-epg-flood
Logos, guide TV et fluidité : couper le flot de requêtes mortes
2026-09-19 12:00:53 +02:00
MichaelandClaude Opus 5 b6ca9dab96 Logos, guide TV et fluidité : couper le flot de requêtes mortes
Logos absents : l'hébergeur des picons du fournisseur répond 503 sur
tout, y compris sa racine (serveur en maintenance). Le proxy relayait
ce 503 au navigateur, qui redemandait chaque logo à chaque affichage de
la grille — des centaines de requêtes sortantes mortes par seconde.
- Un statut >= 400 sur une image rend désormais un pixel transparent
  avec `Cache-Control: max-age=600`, au lieu de propager l'erreur
- Après trois échecs consécutifs, l'hôte est marqué hors service cinq
  minutes : plus aucun appel sortant, réponse servie localement
- Le corps d'erreur amont est drainé, sinon la connexion restait
  ouverte jusqu'au timeout

Guide TV très lent (4 à 8 s par chaîne dans les logs) : `player_api`
demande un appel par chaîne, et le panneau met ~4 s à répondre — une
grille de trente chaînes dépassait la minute.
- Le dump `xmltv.php` du panneau passe en premier : une requête couvre
  toutes les chaînes, réutilisée trois heures
- L'interrogation chaîne par chaîne reste le repli, puis la source
  XMLTV externe
- Une source XMLTV en échec n'est plus retéléchargée à chaque
  consultation : quinze minutes de recul
- Index borné à 48 h en avant : un dump national couvre sept jours pour
  un millier de chaînes, soit des centaines de Mo pour un guide qui
  n'affiche que le programme courant

Lecture saccadée : le relais FFmpeg -> navigateur de `/turbo.ts` ne
transmettait pas la pause du client à la source. Un lecteur plus lent
que le flux laissait FFmpeg produire à pleine vitesse et le serveur
empilait les paquets en mémoire, d'où la dérive derrière le direct. La
contre-pression est maintenant propagée (`pipeWithBackpressure`).

Le serveur tourne sur un seul isolate : le flot de requêtes mortes et
les attentes EPG de plusieurs secondes partageaient la boucle
d'événements avec les paquets vidéo.

Tests ajoutés : cache d'échecs par hôte, contre-pression du relais,
priorité des sources EPG, recul après échec XMLTV, horizon d'index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:57:38 +02:00
Claude 2151f2820c Lecture d'un enregistrement : repli sur le ré-encodage si la copie échoue
Certains conteneurs ou horodatages ne se prêtent pas à `-c:v copy`.
Plutôt qu'un écran d'erreur définitif là où le ré-encodage fonctionnait
jusqu'ici, la session est relancée une fois sans copie quand la première
tentative ne produit pas de playlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
2026-09-10 17:49:24 +00:00
Claude fa8eed38f2 Enregistrements : corriger le démarrage de la lecture
La lecture d'un enregistrement démarrait au milieu du programme et se
coupait aussitôt ; il fallait relancer une deuxième fois pour qu'elle
tienne.

Cause principale, introduite avec le passage de la playlist en `EVENT` :
tant que le transcodage n'est pas terminé, la playlist n'a pas
d'`EXT-X-ENDLIST`, et hls.js traite alors tout contenu comme du direct.
Sans `startPosition` explicite il démarre au « bord du direct » — donc
collé au front d'encodage, sans la moindre avance de segments. D'où un
départ en plein milieu, puis une coupure immédiate. Au second essai, le
transcodage était terminé (ENDLIST présent, contenu traité en VOD) et
tout se passait bien.

Hors direct : `startPosition: 0` (ou la position de reprise), rattrapage
de latence neutralisé (il accélérait la lecture puis forçait un saut en
avant vers un direct inexistant), et délai d'attente de playlist porté de
10 s à 45 s — une session FFmpeg qui démarre n'est plus prise pour un
manifeste mort.

Cause de fond : la lecture ré-encodait la vidéo. Or la capture se fait en
`-c copy`, donc le fichier contient le codec de la chaîne, presque
toujours du H.264, directement lisible en HLS. Le ré-encodage tenait à
peine le temps réel en 1080p, ce qui laissait le lecteur courir après
l'encodeur en permanence. La vidéo est désormais copiée telle quelle
quand elle est en H.264 (seul l'audio est converti en AAC) : la
segmentation va à la vitesse du disque, l'enregistrement devient
navigable en quelques secondes et les sauts ne redémarrent presque plus
jamais FFmpeg. Les codecs que le navigateur ne sait pas lire (HEVC,
MPEG-2…) restent ré-encodés.

En complément :
- le serveur attend trois segments d'avance avant de servir la playlist,
  et sert ce qu'il a plutôt qu'une erreur si le délai expire ;
- le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le lecteur
  mobile le faisait déjà ;
- `XFPlayer.destroy()` retire ses écouteurs : sans ça, une relance
  laissait l'instance précédente réagir aux commandes du parent et
  republier des positions périmées ;
- les appels ffprobe passent par `MediaProbe`, qui mémorise durée et
  codec tant que le fichier ne change pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
2026-09-10 17:48:02 +00:00
Claude b95d72fe80 Enregistrements : lecteur avancé, navigation et reprise
La lecture d'un enregistrement ne permettait ni de reculer, ni d'avancer,
ni de reprendre où on s'était arrêté.

Cause côté serveur : la playlist était servie en
`EXT-X-PLAYLIST-TYPE:VOD`. hls.js la considère alors comme définitive et
ne la relit jamais — il ne voyait donc que les quelques secondes déjà
transcodées à l'ouverture, d'où une durée absurde et une barre de
progression inutilisable. Elle passe en `EVENT` : la zone navigable
grandit au rythme de l'encodage.

FFmpeg transcodant séquentiellement, sauter à 45 min imposait malgré tout
d'attendre qu'il y arrive. La playlist accepte donc `?start=<secondes>` et
démarre une session dédiée (`-ss` avant `-i`). Les segments y sont
préfixés par l'offset (`t2700/segment_000.ts`) : la query string ne
survit pas aux URLs relatives de la playlist, deux sessions du même
enregistrement se seraient mélangées. Les sessions d'offsets abandonnés
sont fermées dès qu'aucun lecteur ne les consomme (killIdleSiblings).

Côté lecteur, tout ce qui sort du moteur est désormais en temps absolu
(`offset` réinjecté) : une recherche dans la zone déjà transcodée est un
simple `currentTime`, au-delà le lecteur répond `seek_out_of_range` et
l'app relance le flux à cet instant.

Ajouts visibles :
- reprise proposée à l'ouverture d'un enregistrement entamé, et mention
  « Reprendre à … » avec sa progression dans la liste ;
- durée réelle mesurée par ffprobe (`duration_seconds`), mise en cache
  tant que le fichier ne bouge pas — `end_time - start_time` n'est que la
  durée programmée ;
- sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà
  transcodée visible sur la barre ;
- raccourcis K/espace, J/L, Maj+←/→, 0-9, F, M, virgule/point.

L'identifiant de vue de plateforme porte maintenant un numéro de
génération : sans lui, réutiliser le même identifiant laissait l'ancienne
iframe en place (`registerViewFactory` conserve la première fabrique
enregistrée) — ce qui affectait déjà le retour sur une chaîne
précédemment zappée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
2026-09-10 16:00:13 +00:00
MichaelandClaude Opus 5 d6c7184475 Lecture : un échec de chargement de mpegts.js n'est plus fatal
L'écran « Échec du chargement : vendor/mpegts.min.js » était un cul-de-sac :
la moindre requête ratée sur la lib condamnait la chaîne, alors que le flux
restait parfaitement lisible autrement.

- xf-player-core : le chargement des libs est retenté une fois en
  contournant le cache HTTP (les libs sont servies en max-age=86400, une
  entrée tronquée bloquait le lecteur jusqu'au vidage manuel du cache), et
  seul un succès est mémorisé — la promesse rejetée restait en cache et
  rendait tout réessai impossible pour le reste de la session.
- Repli automatique : si mpegts.js reste introuvable, le live bascule sur la
  route HLS équivalente (où FFmpeg réencode déjà l'audio en AAC) ; si hls.js
  reste introuvable, on tente le HLS natif. Plus d'écran d'erreur quand un
  chemin de lecture est encore disponible.
- Message d'erreur exploitable : la cause réelle est affichée (HTTP 404,
  429, réseau injoignable) au lieu d'un échec indifférencié.
- Préchargement corrigé : les trois players préchargeaient hls.js en dur,
  soit 618 Ko téléchargés pour rien à chaque zap TV — c'est mpegts.js qui
  sert au direct — au détriment du flux et du chargement de mpegts.js
  lui-même. Le préchargement suit désormais le flux demandé, avec la même
  décision que XFPlayer.start() (et ne charge rien sur Safari/iOS).
- Serveur : avertissement explicite au démarrage si web/vendor/*.min.js
  manque, au lieu de laisser le navigateur échouer sans explication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FSovdudi3KBwZK3aC42fw
2026-09-09 16:40:31 +00:00
LogiFlow 175cff560c Merge pull request #11 from R0m1k3/claude/lecture-video
Lecture vidéo : son garanti sur toutes les chaînes, zapping et démarrages accélérés
2026-08-28 09:01:05 +02:00
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
115 changed files with 17654 additions and 6957 deletions

No files matched your search

+1 -3
View File
@@ -6,9 +6,7 @@
.packages
.pub-cache
build/
bin/.dart_tool
bin/.packages
bin/pubspec.lock
server/target
ios/
android/
windows/
+27 -6
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,24 +19,39 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
# Version épinglée : `channel: stable` seul fait dériver le SDK au
# fil du temps (une release Flutter peut casser la CI sans aucun
# changement dans le dépôt). Le cache évite de retélécharger SDK et
# dépendances pub à chaque run.
flutter-version: 3.47.6
channel: stable
cache: true
- run: flutter pub get
# Infos (pre-existing withOpacity/dart:html deprecations) are not
# fatal; errors and warnings still fail the build.
- run: flutter analyze --no-fatal-infos
- run: flutter test
# Moteur de lecture web (web/xf-player-core.js) : Node est préinstallé
# sur les runners ubuntu, aucune dépendance npm.
- run: node --test "test/web/*_test.mjs"
- run: flutter build web --release
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: bin
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
# Toolchain épinglée : la même que l'image Docker (rust:1.98.1).
- uses: dtolnay/rust-toolchain@master
with:
sdk: stable
- run: dart pub get
- run: dart analyze
- run: dart test
toolchain: 1.98.1
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: server
- run: cargo fmt --check
- run: cargo build --locked
- run: cargo test --locked
- run: cargo clippy --locked -- -D warnings
+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
+6
View File
@@ -49,4 +49,10 @@ 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
# Serveur Rust : lockfile versionné (build Docker en --locked).
!Cargo.lock
/server/target/
.claude/settings.local.json
+7 -1
View File
@@ -11,10 +11,16 @@ Since you are currently using Docker, you likely don't have these installed on y
* Extract the zip file to `C:\src\flutter` (for example).
* Add `flutter/bin` to your **System PATH**.
2. **Verify Installation**
2. **Install Rust** (the server is written in Rust)
* Download `rustup-init.exe` from [rustup.rs](https://rustup.rs) and accept the defaults
(it installs the MSVC toolchain; Visual Studio Build Tools with the "C++ build tools"
workload are required).
3. **Verify Installation**
* Open a new terminal.
* Run `flutter doctor`.
* Ensure it says "Flutter ... channel stable".
* Run `cargo --version`.
## Building the App
+66
View File
@@ -1,5 +1,71 @@
# 📝 Changelog - XtremFlow Optimisations
## Non publié
### 🦀 Serveur réécrit en Rust
- **Le serveur Dart (`bin/`) est remplacé par un serveur Rust (`server/`)** : tokio + axum, reqwest/rustls, SQLite embarqué (rusqlite). Même API, mêmes routes, mêmes réponses : le front Flutter n'a pas changé. Le binaire est multi-thread (plus de boucle d'événements unique sur laquelle un gros JSON ou un dump XMLTV gelait les flux en cours) et pèse ~9 Mo
- **Base reprise telle quelle** : schéma, migrations numérotées et formats de dates (`DateTime.toIso8601String()` de Dart) identiques ; sessions, playlists, enregistrements et season passes existants restent valides, les hachages SHA-256 historiques sont toujours migrés vers bcrypt à la connexion
- **Comportement conservé** : cascade de routage (API → proxy Xtream → streaming → statique), anti-SSRF avec redirections revalidées, logs masqués, rate limits, honeypot, CORS, versionnage des assets, transcodage HLS/turbo.ts avec repli GPU→CPU, quotas de connexions fournisseur, reprise/relance/fusion des enregistrements, season passes, EPG (dump du panneau → XMLTV externe → `player_api`), logos de repli, arrêt gracieux sur SIGTERM (FFmpeg arrêté par SIGTERM, jamais SIGKILL d'emblée)
- **Tests** : les tests backend Dart sont portés en Rust (`cargo test`, 132 tests) ; la CI exécute `cargo fmt --check`, `cargo test` et `cargo clippy -D warnings`
- Le message d'erreur 502 d'un transcodage échoué est désormais masqué (l'erreur FFmpeg pouvait recopier l'URL du panneau avec les identifiants)
### 🖼️ Logos des chaînes
- **Logos de repli quand l'hébergeur de picons tombe** : nouvelle route `/api/logo?src=…&name=…`. L'URL du panneau est tentée d'abord (via le proxy, mêmes protections anti-SSRF) ; en cas d'échec, le logo est cherché par nom de chaîne dans le dépôt public [`tv-logo/tv-logos`](https://github.com/tv-logo/tv-logos) — préfixe pays et suffixes de qualité ignorés, `Canal+` → `canal-plus`, repli sur la marque pour les canaux événementiels (`RMC SPORT LIVE 5` → logo RMC Sport). La liste des logos est mise en cache 24 h (l'API GitHub non authentifiée plafonne à 60 requêtes/h), les images en mémoire. Les chaînes sans `stream_icon` y gagnent aussi un logo
- **Plus d'acharnement sur un hébergeur de picons hors service** : une image en erreur n'est plus relayée telle quelle au navigateur, qui la redemandait à chaque affichage de la grille. Réponse 410 mise en cache dix minutes (410 et non 404 : la `Cascade` du serveur rattrape les 404 et perdait l'en-tête de cache) ; au bout de trois échecs consécutifs, l'hôte est considéré hors service cinq minutes et plus aucune requête ne sort. Un statut d'erreur plutôt qu'un pixel transparent : le client affiche son propre repli au lieu d'une tuile vide
- **Logos de l'onglet Enregistrements** : l'URL `http://` du panneau y était chargée directement, bloquée par le navigateur sur une page servie en HTTPS
### 📺 Guide TV (EPG)
- **Guide servi par le dump `xmltv.php` du panneau** : `player_api` réclamait un appel par chaîne, à plusieurs secondes pièce chez la plupart des revendeurs — une grille de trente chaînes mettait plus d'une minute à se remplir. Le dump du panneau couvre toutes les chaînes en une requête, réutilisée trois heures. L'interrogation chaîne par chaîne reste le repli quand le dump est absent ou ne couvre pas la chaîne, suivie de la source XMLTV externe
- **Une source XMLTV morte n'est plus retéléchargée à chaque consultation** : après un échec, la source est laissée de côté quinze minutes
- **Index XMLTV borné à 48 h** : un dump national couvre sept jours pour un millier de chaînes ; tout garder coûtait des centaines de mégaoctets au conteneur pour un guide qui n'affiche que le programme courant et les suivants
### ▶️ Lecture vidéo
- **Fix du démarrage des enregistrements** : la lecture partait au milieu du programme et se coupait aussitôt, obligeant à relancer une deuxième fois. Une playlist encore en cours de transcodage n'a pas d'`EXT-X-ENDLIST` : hls.js la traite comme du direct et démarrait donc au « bord du direct », collé au front d'encodage, sans aucune avance de segments. `startPosition: 0` hors direct, plus la neutralisation du rattrapage de latence (qui accélérait la lecture puis forçait un saut en avant pour rejoindre un direct inexistant)
- **Plus de ré-encodage inutile à la lecture d'un enregistrement** : la capture étant faite en copie, le fichier contient déjà du H.264 dans la quasi-totalité des cas. Il est désormais servi tel quel (`-c:v copy`, seul l'audio est converti en AAC) au lieu d'être ré-encodé à peine plus vite que le temps réel — la segmentation va maintenant à la vitesse du disque, l'enregistrement devient navigable en quelques secondes. Les codecs illisibles par le navigateur (HEVC, MPEG-2…) restent ré-encodés
- **Démarrage plus robuste** : le serveur attend trois segments d'avance avant de servir la playlist (au lieu d'un seul, qui laissait le lecteur courir après l'encodeur), et sert ce qu'il a plutôt qu'une erreur si le délai expire. Côté navigateur, le délai d'attente de playlist passe de 10 s à 45 s hors direct — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort — et le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le faisait déjà le lecteur mobile
- **Lecteur avancé pour les enregistrements** : barre de progression réellement utilisable (avance, retour, saut n'importe où), pause/reprise, et reprise automatique là où on s'était arrêté. La playlist d'un enregistrement était servie en `EXT-X-PLAYLIST-TYPE:VOD` : hls.js la considérait comme définitive et ne voyait donc que les quelques secondes déjà transcodées à l'ouverture. Elle passe en `EVENT` (la zone navigable grandit avec l'encodage) et un saut hors de cette zone relance FFmpeg à la position visée (`?start=`), au lieu d'attendre que l'encodeur y arrive
- **Reprise des enregistrements** : la liste affiche « Reprendre à … » avec la progression, et propose au clic de reprendre ou de repartir du début ; la durée affichée est mesurée par ffprobe (`duration_seconds`) et non plus déduite des horaires programmés — un enregistrement arrêté en avance donnait une barre fausse
- **Contrôles enrichis (VOD, séries, enregistrements)** : sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre, et raccourcis clavier `K`/espace, `J`/`L`, Maj+←/→, `0`-`9`, `F`, `M`, `,`/`.`
- **Fix du son manquant sur certaines chaînes** : nouvelle route `/api/live/<id>/turbo.ts` pour le zapping — vidéo copiée telle quelle, audio systématiquement réencodé en AAC côté serveur. Les chaînes en AC-3/E-AC-3/MP2 (que mpegts.js ne décode pas) ont maintenant du son, sans coût de transcodage vidéo
- **Players mis à jour** : hls.js 1.6.7 → 1.7.1, mpegts.js 1.7.3 → 1.8.2 (vendorisés)
- **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur)
- **Latence live maîtrisée** : rattrapage du direct activé dans mpegts.js (profil rapide) — les micro-coupures ne font plus dériver la lecture derrière le direct
- **Fix « Échec du chargement : vendor/mpegts.min.js »** : un échec de chargement d'une lib de lecture n'affiche plus un écran d'erreur définitif. Le chargement est retenté une fois en contournant le cache HTTP (une entrée tronquée condamnait le lecteur jusqu'au vidage manuel du cache), le résultat n'est mémorisé qu'en cas de succès (une promesse rejetée en cache rendait tout réessai impossible) et, si la lib reste introuvable, le live bascule automatiquement sur la route HLS équivalente. Le message affiché précise désormais la cause (HTTP 404, 429, réseau injoignable)
- **Préchargement de la bonne lib** : les trois players préchargeaient hls.js en dur, soit 618 Ko téléchargés pour rien à chaque zap TV — où c'est mpegts.js qui sert — au détriment du flux et du chargement de mpegts.js. Le préchargement suit maintenant le flux réellement demandé (et ne charge rien sur Safari/iOS, qui lit le HLS nativement)
- **Contre-pression sur le flux turbo** : le relais de FFmpeg vers le navigateur ne transmettait pas la pause du client à la source. Un lecteur plus lent que le flux — réseau domestique, onglet en arrière-plan — laissait FFmpeg produire à pleine vitesse pendant que le serveur empilait les paquets en mémoire : la lecture dérivait derrière le direct et saccadait
- **Alerte au démarrage** : le serveur signale explicitement l'absence de `web/vendor/*.min.js` au lancement, au lieu de laisser le navigateur échouer sans explication
### 🧰 Qualité / Infra
- **Builds reproductibles** : `pubspec.lock` (frontend et backend) désormais versionnés et utilisés par le Dockerfile — chaque build résolvait jusqu'ici des versions fraîches
- **CI durcie** : versions Flutter/Dart épinglées, cache pub, annulation des runs obsolètes (`concurrency`) ; `docker-publish` ne publie plus `:latest` qu'après une CI verte sur main (il tournait en parallèle et pouvait publier une image cassée)
- **docker-compose** : défaut `RECORDINGS_PATH` porté à `./data/recordings` (l'ancien défaut était un chemin unRAID spécifique à une machine), variable `TZ` ajoutée
- **README réécrit** : il décrivait une architecture disparue (Hive/IndexedDB, SHA-256, dhttpd port 8080) — remplacé par l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, CI)
- `DEPLOYMENT_CHECKLIST.md` archivé et avertissement ajouté sur `docs/archive/` (plusieurs documents s'y déclarent « COMPLETE » à tort)
### ✨ Fonctionnalités
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
- **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é
+45 -11
View File
@@ -1,7 +1,26 @@
# Stage 1: Build Environment (Optimized for Docker Caching)
FROM ghcr.io/cirruslabs/flutter:stable AS builder
# SDK Flutter officiel, version épinglée — la même que la CI. L'image
# ghcr.io/cirruslabs/flutter:stable n'est plus mise à jour : elle était
# restée sur Flutter 3.44.0 / Dart 3.12.0 (mai 2026), loin derrière la
# version stable réelle.
FROM debian:stable-slim AS builder
ARG FLUTTER_VERSION=3.47.6
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
unzip \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" \
| tar -xJ -C /opt \
&& git config --global --add safe.directory /opt/flutter
ENV PATH="/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:${PATH}"
USER root
WORKDIR /app
# Optimize DART VM Memory
@@ -12,10 +31,10 @@ ENV FLUTTER_NO_ANALYTICS=1
RUN flutter config --enable-web && flutter precache --web
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
COPY pubspec.yaml ./
COPY bin/pubspec.yaml ./bin/
# Note: No .lock files found locally, so we fetch fresh ones here
RUN flutter pub get && cd bin && dart pub get
# Le lockfile est versionné : le build résout exactement les versions
# committées au lieu d'en chercher de nouvelles à chaque build.
COPY pubspec.yaml pubspec.lock ./
RUN flutter pub get
# 3. Source Code Copy (CHANGES OFTEN)
# Everything except what's in .dockerignore (built-in protection)
@@ -28,18 +47,33 @@ RUN dart run build_runner build --delete-conflicting-outputs
# This step MUST run if you modified any Dart file.
RUN flutter build web --release --base-href="/" --no-tree-shake-icons --no-pub
# 6. Native Server Compilation
RUN cd bin && dart compile exe server.dart -o server
# ============================================
# Stage 2: Serveur Rust
# ============================================
# Bookworm (glibc 2.36) : le binaire tourne tel quel sur le runtime
# debian:stable-slim, plus récent. Version épinglée comme le SDK Flutter.
FROM rust:1.98.1-slim-bookworm AS server-builder
WORKDIR /server
# Dépendances compilées dans une couche à part : elles ne sont recompilées
# que si Cargo.toml/Cargo.lock changent, pas à chaque modification du code.
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/xtremflow-server* target/release/deps/xtremflow_server*
COPY server/src ./src
RUN cargo build --release --locked
# ============================================
# Stage 2: Production Runtime
# Stage 3: Production Runtime
# ============================================
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
sqlite3 \
libsqlite3-dev \
curl \
wget \
xz-utils \
@@ -61,7 +95,7 @@ RUN mkdir -p /app/data /app/web /app/recordings /tmp/xtremflow_streams \
&& chown -R xtremuser:xtremuser /app /tmp/xtremflow_streams
COPY --from=builder /app/build/web /app/web
COPY --from=builder /app/bin/server /app/server
COPY --from=server-builder /server/target/release/xtremflow-server /app/server
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/server /app/entrypoint.sh
+13 -19
View File
@@ -1,45 +1,39 @@
# Dockerfile optimisé pour mises à jour rapides (Fast-Path)
# Ce Dockerfile assume que les fichiers Flutter Web ont déjà été compilés en local
# dans le dossier `build/web`.
# dans le dossier `build/web`. Seul le serveur Rust est compilé ici.
FROM rust:1.98.1-slim-bookworm AS server-builder
WORKDIR /server
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/xtremflow-server* target/release/deps/xtremflow_server*
COPY server/src ./src
RUN cargo build --release --locked
FROM debian:stable-slim
# Éviter les prompts à l'installation
ENV DEBIAN_FRONTEND=noninteractive
# Installation des dépendances minimales (Dart + FFmpeg)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
unzip \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Installation de Dart SDK (version spécifique pour compatibilité)
RUN wget https://storage.googleapis.com/dart-archive/channels/stable/release/3.3.0/sdk/dartsdk-linux-x64-release.zip -O /tmp/dart-sdk.zip && \
unzip /tmp/dart-sdk.zip -d /usr/lib && \
rm /tmp/dart-sdk.zip
ENV PATH="/usr/lib/dart-sdk/bin:${PATH}"
# Création de l'utilisateur non-root
RUN useradd -m xtremuser
WORKDIR /app
# Création des dossiers de données avec permissions
RUN mkdir -p /app/data/logs /app/data/tmp /app/web && \
RUN mkdir -p /app/data/logs /app/data/tmp /app/web /app/recordings && \
chown -R xtremuser:xtremuser /app
# Copie des fichiers pré-compilés du serveur (doit être compilé en local si possible, sinon ici)
COPY --chown=xtremuser:xtremuser bin/ /app/bin/
RUN cd bin && dart pub get
COPY --from=server-builder /server/target/release/xtremflow-server /app/server
# Copie des fichiers pré-compilés du frontend (build/web doit exister en local)
COPY --chown=xtremuser:xtremuser build/web/ /app/web/
USER xtremuser
EXPOSE 8089
# Commande de lancement (compilation JIT du serveur pour rapidité de build, ou EXE si déjà fait)
# Pour une rapidité maximale de "build", on lance le script dart directement car la compilation EXE prend du temps.
CMD ["dart", "bin/server.dart", "--port", "8089", "--path", "/app/web"]
CMD ["/app/server", "--port", "8089", "--path", "/app/web"]
+78 -254
View File
@@ -1,282 +1,106 @@
# 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 Rust 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 | Rust (tokio, axum, reqwest/rustls, rusqlite), binaire natif unique |
| Base | SQLite côté serveur (`/app/data/xtremflow.db`) |
| Capture/Transcodage | FFmpeg (build BtbN, NVENC inclus) |
| Conteneur | Debian slim multi-stage, port **8089** |
✅ **Live TV with EPG**
- Electronic Program Guide (EPG) overlay
- "Now & Next" program display
- Real-time progress bar
✅ **VOD & Series**
- Movies and Series organized by categories
- Grid layout with posters
- Optimized ratings display (1 decimal place)
✅ **Docker Deployment**
- Multi-stage build with Flutter and Dart
- Custom Dart Server (`bin/server.dart`)
- **FFmpeg Transcoding** for mobile compatibility
- **Cache Management** system for temporary files
- External network support (`nginx_default`)
## Tech Stack
- **Framework**: Flutter Web
- **State Management**: Riverpod
- **Local Database**: Hive (Web IndexedDB) with AES encryption
- **Networking**: Dio with cache interceptors
- **Routing**: GoRouter with auth guards
- **Video Player**: `video_player` + `chewie`
- **UI**: Google Fonts, Material Design 3
## Prerequisites
- Docker & Docker Compose
- Existing `nginx_default` network (for reverse proxy routing)
- Flutter SDK (for local development only)
## Quick Start (Docker)
### 1. Build the Docker image
## Démarrage rapide (Docker)
```bash
docker-compose build
docker-compose up -d --build
# Application sur http://localhost:8089
```
### 2. Start the container
```bash
docker-compose up -d
```
### 3. Access via reverse proxy
Configure your reverse proxy (Nginx/Traefik) to route traffic to:
- **Container**: `xtremflow`
- **Internal Port**: `8080`
- **Network**: `nginx_default`
Example Nginx configuration:
```nginx
location /iptv {
proxy_pass http://xtremflow:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
### 4. Login
- **URL**: `http://your-domain/iptv`
- **Default Credentials**:
- Username: `admin`
- Password: `admin`
⚠️ **Change the admin password immediately after first login!**
## Local Development
### Install dependencies
Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléatoire affiché une seule fois dans les logs** (`docker logs xtremflow`), sauf si `ADMIN_INITIAL_PASSWORD` est défini. Changez-le après la première connexion.
### Variables d'environnement (docker-compose.yml)
| Variable | Défaut | Rôle |
|---|---|---|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
| `RECORDINGS_QUOTA_GB` | `0` (off) | Quota du dossier d'enregistrements (rotation des plus anciens terminés) |
| `TRUSTED_PROXIES` | loopback + RFC1918 | IPs de reverse proxy dont `X-Forwarded-For` est honoré |
## Développement local
```bash
# Frontend
flutter pub get
flutter analyze && flutter test
flutter run -d chrome # nécessite le backend lancé pour les routes /api
# Backend (Rust ≥ 1.98)
cd server
cargo test
cargo clippy -- -D warnings
XTREMFLOW_DB_PATH=./dev.db cargo run -- --port 8089 --path ../build/web
```
### Generate Hive adapters (if modified)
`XTREMFLOW_DB_PATH` (défaut `/app/data/xtremflow.db`) et `RECORDINGS_DIR` (défaut `/app/recordings`) permettent de lancer le serveur hors conteneur. La base SQLite est compatible avec celle de l'ancien serveur Dart : schéma, migrations et formats de dates sont identiques, une base existante est reprise telle quelle.
```bash
flutter pub run build_runner build --delete-conflicting-outputs
```
Le build Windows natif est documenté dans `BUILD.md`.
### 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
server/ Serveur Rust (crate xtremflow-server)
└── src/
├── main.rs Démarrage, arrêt gracieux (SIGTERM : clôture des enregistrements)
├── server.rs Middlewares (logs masqués, sécurité, rate limit, CORS) et cascade de routage
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy, streaming…)
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV, logos
├── db.rs SQLite (schéma, migrations)
└── util/ Masquage des identifiants, chemins sûrs, mots de passe, ffprobe
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.
-6
View File
@@ -1,11 +1,5 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# bin/ is a standalone Dart package (server) with its own pubspec and
# analysis; it is analyzed by the backend CI job via `dart analyze`.
- bin/**
linter:
rules:
- prefer_const_constructors
-163
View File
@@ -1,163 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class AuthHandler {
final AppDatabase db;
AuthHandler(this.db);
Router get router {
final router = Router();
router.post('/login', _login);
router.post('/logout', _logout);
router.get('/me', _getCurrentUser);
return router;
}
/// POST /api/auth/login
Future<Response> _login(Request request) async {
try {
print('[Auth] Login attempt received');
final bodyStr = await request.readAsString();
final payload = jsonDecode(bodyStr) as Map<String, dynamic>;
final username = payload['username'] as String?;
final password = payload['password'] as String?;
if (username == null || password == null) {
print('[Auth] Missing username or password');
return Response(400, body: jsonEncode({
'success': false,
'error': 'Username and password are required',
}), headers: {'Content-Type': 'application/json'},);
}
print('[Auth] Verifying credentials for user: $username');
// Verify credentials
final user = db.verifyCredentials(username, password);
if (user == null) {
print('[Auth] Invalid credentials for user: $username');
return Response(401, body: jsonEncode({
'success': false,
'error': 'Invalid credentials',
}), headers: {'Content-Type': 'application/json'},);
}
print('[Auth] User verified, creating session for userId: ${user.id}');
// Create session
final session = db.createSession(user.id);
// HttpOnly session cookie so same-origin media requests (hls.js inside
// the player iframe cannot send Authorization headers) are authenticated.
final maxAge = session.expiresAt.difference(DateTime.now()).inSeconds;
return Response.ok(jsonEncode({
'success': true,
'user': user.toJson(),
'token': session.token,
}), headers: {
'Content-Type': 'application/json',
'Set-Cookie':
'session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=$maxAge',
},);
} catch (e, stackTrace) {
print('[Auth] ERROR during login: $e');
print('[Auth] Stack trace: $stackTrace');
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/auth/logout
Future<Response> _logout(Request request) async {
try {
final token = _extractToken(request);
if (token == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
db.deleteSession(token);
return Response.ok(jsonEncode({
'success': true,
}), headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0',
},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// GET /api/auth/me
Future<Response> _getCurrentUser(Request request) async {
try {
final token = _extractToken(request);
if (token == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final session = db.findSessionByToken(token);
if (session == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Invalid or expired session',
}), headers: {'Content-Type': 'application/json'},);
}
final user = db.findUserById(session.userId);
if (user == null) {
return Response(404, body: jsonEncode({
'success': false,
'error': 'User not found',
}), headers: {'Content-Type': 'application/json'},);
}
return Response.ok(jsonEncode({
'user': user.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// Extract token from Authorization header or cookie
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
if (authHeader != null && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Try cookie
final cookie = request.headers['cookie'];
if (cookie != null) {
final parts = cookie.split(';');
for (final part in parts) {
final trimmed = part.trim();
if (trimmed.startsWith('session=')) {
return trimmed.substring(8);
}
}
}
return null;
}
}
-298
View File
@@ -1,298 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../services/xmltv_epg_service.dart';
/// API EPG — proxy vers Xtream avec cache 30 minutes
/// GET /api/epg/<channel_id>?days=1
///
/// Le panneau de l'abonné reste la source de référence. Une source XMLTV
/// externe n'est interrogée qu'en dernier recours, quand le panneau ne rend
/// aucun programme couvrant l'instant présent ou à venir — cas fréquent des
/// revendeurs dont le guide est figé depuis plusieurs jours.
class EpgApi {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
/// Repli XMLTV. `null` quand aucune source n'est configurée : l'API se
/// comporte alors exactement comme avant, sans le moindre appel sortant.
final XmltvEpgService? _xmltv;
/// Cache mémoire : « dns|username|channelId » → {data, expiresAt}.
///
/// La clé inclut le compte : les `stream_id` sont propres à chaque panneau,
/// deux playlists distinctes peuvent parfaitement partager le même
/// identifiant et se servaient alors le guide l'une de l'autre.
final Map<String, _CacheEntry> _cache = {};
/// Correspondance `stream_id` → identifiant EPG et nom, par compte.
/// Nécessaire au repli : le dump XMLTV indexe par identifiant de chaîne,
/// pas par `stream_id` propre au panneau.
final Map<String, _ChannelMap> _channelMaps = {};
EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv;
Future<Response> handleGetEpg(Request request, String channelId) async {
final playlist = await _getPlaylist(request);
if (playlist == null) {
return Response.forbidden(
json.encode({'error': 'Playlist non trouvée'}),
headers: {'Content-Type': 'application/json'},
);
}
final cacheKey = '${playlist.dns}|${playlist.username}|$channelId';
final cached = _cache[cacheKey];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return Response.ok(
cached.data,
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
);
}
try {
final dns = playlist.dns;
Map<String, dynamic> epgData = {
'channel_id': channelId,
'programmes': [],
};
// Ordre d'essai des actions Xtream.
//
// `get_simple_data_table` d'abord : c'est la seule action réellement
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
// d'une erreur, ils renvoient poliment le payload d'authentification,
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
// à distinguer d'une chaîne sans programme.
const actions = [
'get_simple_data_table',
'get_short_epg',
];
for (final action in actions) {
final url =
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
'&action=$action&stream_id=$channelId';
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode != 200) continue;
try {
epgData = transformEpgData(json.decode(response.body), channelId);
} catch (_) {
continue;
}
if ((epgData['programmes'] as List).isNotEmpty) break;
}
var source = 'xtream';
if (!_hasCurrentProgramme(epgData)) {
final fallback = await _xmltvProgrammes(playlist, channelId);
if (fallback.isNotEmpty) {
epgData = {'channel_id': channelId, 'programmes': fallback};
source = 'xmltv';
}
}
final jsonStr = json.encode(epgData);
// Mettre en cache 30 minutes
_cache[cacheKey] = _CacheEntry(
data: jsonStr,
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
);
return Response.ok(
jsonStr,
headers: {
'Content-Type': 'application/json',
'X-Cache': 'MISS',
'X-Epg-Source': source,
},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// Le guide contient-il un programme en cours ou à venir ?
///
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
/// tous terminés depuis plusieurs jours. Compter les entrées ne suffit donc
/// pas à décider si le guide est exploitable.
static bool _hasCurrentProgramme(Map<String, dynamic> epgData) {
final now = DateTime.now().toUtc();
for (final programme in epgData['programmes'] as List) {
if (programme is! Map) continue;
final end = DateTime.tryParse('${programme['end']}');
if (end != null && end.isAfter(now)) return true;
}
return false;
}
/// Programmes issus du dump XMLTV, ou liste vide si indisponible.
Future<List<Map<String, dynamic>>> _xmltvProgrammes(
PlaylistConfig playlist,
String channelId,
) async {
final xmltv = _xmltv;
if (xmltv == null) return const [];
try {
final channels = await _channelMapFor(playlist);
final epgId = channels.epgIds[channelId];
final name = channels.names[channelId];
if (epgId == null && name == null) return const [];
final programmes =
await xmltv.programmesFor(epgId, displayName: name);
if (programmes.isEmpty) return const [];
final now = DateTime.now().toUtc();
return programmes
.where((p) => p.stop.isAfter(now))
.take(64)
.map((p) => p.toJson(channelId))
.toList();
} catch (e) {
print('[EpgApi] repli XMLTV indisponible pour $channelId : $e');
return const [];
}
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async {
final key = '${playlist.dns}|${playlist.username}';
final cached = _channelMaps[key];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return cached;
}
final url = '${playlist.dns}/player_api.php'
'?username=${playlist.username}&password=${playlist.password}'
'&action=get_live_streams';
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
final epgIds = <String, String>{};
final names = <String, String>{};
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
if (decoded is List) {
for (final item in decoded) {
if (item is! Map) continue;
final streamId = item['stream_id']?.toString();
if (streamId == null || streamId.isEmpty) continue;
final epgId = item['epg_channel_id']?.toString();
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
final name = item['name']?.toString();
if (name != null && name.isNotEmpty) names[streamId] = name;
}
}
}
final map = _ChannelMap(
epgIds: epgIds,
names: names,
expiresAt: DateTime.now().add(const Duration(hours: 6)),
);
_channelMaps[key] = map;
return map;
}
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi au
/// client. Public pour être couvert par `bin/test/epg_api_test.dart`.
Map<String, dynamic> transformEpgData(dynamic raw, String channelId) {
try {
List<dynamic> listings = [];
if (raw is Map && raw.containsKey('epg_listings')) {
listings = raw['epg_listings'] as List<dynamic>? ?? [];
} else if (raw is List) {
listings = raw;
}
final programmes = listings.map((item) {
// Les champs texte `start`/`end` sont exprimés dans le fuseau du
// panneau (souvent Europe/Amsterdam), sans indicateur de zone : le
// client les relisait comme de l'heure locale et décalait tout le
// guide. Les `*_timestamp` sont de l'epoch UTC — on s'en sert dès
// qu'ils sont présents et on émet de l'ISO-8601 UTC explicite.
final start = _isoUtc(item['start_timestamp']) ??
_normalizeDate(item['start'] as String? ?? '');
final end = _isoUtc(item['stop_timestamp']) ??
_isoUtc(item['end_timestamp']) ??
_normalizeDate(
(item['stop'] as String?) ?? (item['end'] as String?) ?? '',
);
// Décoder le titre (base64 si nécessaire)
String title = item['title'] as String? ?? '';
try {
if (title.isNotEmpty) {
final decoded = utf8.decode(base64Decode(title));
if (decoded.isNotEmpty) title = decoded;
}
} catch (_) {}
String description = item['description'] as String? ?? '';
try {
if (description.isNotEmpty) {
final decoded = utf8.decode(base64Decode(description));
if (decoded.isNotEmpty) description = decoded;
}
} catch (_) {}
return {
'title': title,
'description': description,
'start': start,
'end': end,
'channel_id': channelId,
};
}).toList();
return {'channel_id': channelId, 'programmes': programmes};
} catch (e) {
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
}
}
/// Epoch (secondes, String ou int) → ISO-8601 UTC, ou `null` si absent.
static String? _isoUtc(dynamic timestamp) {
if (timestamp == null) return null;
final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp');
if (seconds == null || seconds <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true)
.toIso8601String();
}
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
static String _normalizeDate(String raw) {
if (raw.contains(' ') && !raw.contains('T')) {
return raw.replaceFirst(' ', 'T');
}
return raw;
}
}
class _ChannelMap {
final Map<String, String> epgIds;
final Map<String, String> names;
final DateTime expiresAt;
_ChannelMap({
required this.epgIds,
required this.names,
required this.expiresAt,
});
}
class _CacheEntry {
final String data;
final DateTime expiresAt;
_CacheEntry({required this.data, required this.expiresAt});
}
-191
View File
@@ -1,191 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class PlaylistsHandler {
final AppDatabase db;
PlaylistsHandler(this.db);
Router get router {
final router = Router();
router.get('/', _getPlaylists);
router.post('/', _createPlaylist);
router.put('/<id>', _updatePlaylist);
router.delete('/<id>', _deletePlaylist);
return router;
}
/// GET /api/playlists
Future<Response> _getPlaylists(Request request) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final playlists = db.getPlaylists(userId);
return Response.ok(jsonEncode({
'playlists': playlists.map((p) => p.toJson()).toList(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/playlists
Future<Response> _createPlaylist(Request request) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final name = payload['name'] as String?;
final serverUrl = payload['serverUrl'] as String?;
final username = payload['username'] as String?;
final password = payload['password'] as String?;
final dns = payload['dns'] as String?;
if (name == null || serverUrl == null || username == null || password == null) {
return Response(400, body: jsonEncode({
'success': false,
'error': 'Missing required fields',
}), headers: {'Content-Type': 'application/json'},);
}
final playlist = db.createPlaylist(
userId: userId,
name: name,
serverUrl: serverUrl,
username: username,
password: password,
dns: dns ?? serverUrl,
);
return Response.ok(jsonEncode({
'success': true,
'playlist': playlist.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// PUT /api/playlists/:id
Future<Response> _updatePlaylist(Request request, String id) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
// Verify playlist belongs to user
final existing = db.getPlaylistById(id);
if (existing == null) {
return Response(404, body: jsonEncode({
'success': false,
'error': 'Playlist not found',
}), headers: {'Content-Type': 'application/json'},);
}
if (existing.userId != userId) {
return Response(403, body: jsonEncode({
'success': false,
'error': 'Forbidden',
}), headers: {'Content-Type': 'application/json'},);
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final name = payload['name'] as String? ?? existing.name;
final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl;
final username = payload['username'] as String? ?? existing.username;
// Empty password means "keep current": clients never receive the real
// password, so edit forms send it back blank.
final incomingPassword = payload['password'] as String?;
final password = (incomingPassword == null || incomingPassword.isEmpty)
? existing.password
: incomingPassword;
final dns = payload['dns'] as String? ?? existing.dns;
final playlist = db.updatePlaylist(
playlistId: id,
name: name,
serverUrl: serverUrl,
username: username,
password: password,
dns: dns,
);
return Response.ok(jsonEncode({
'success': true,
'playlist': playlist.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/playlists/:id
Future<Response> _deletePlaylist(Request request, String id) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
// Verify playlist belongs to user
final existing = db.getPlaylistById(id);
if (existing == null) {
return Response(404,body: jsonEncode({
'success': false,
'error': 'Playlist not found',
}), headers: {'Content-Type': 'application/json'},);
}
if (existing.userId != userId) {
return Response(403, body: jsonEncode({
'success': false,
'error': 'Forbidden',
}), headers: {'Content-Type': 'application/json'},);
}
db.deletePlaylist(id);
return Response.ok(jsonEncode({
'success': true,
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-244
View File
@@ -1,244 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
/// Returns true when [host] must never be proxied (loopback, private LAN,
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
/// bypass the playlist-domain allowlist.
bool isForbiddenProxyHost(String host) {
final lower = host.toLowerCase();
if (lower == 'localhost' || lower == '::1') return true;
final ip = InternetAddress.tryParse(lower);
if (ip == null) return false; // Hostname: validated by domain allowlist path
if (ip.isLoopback || ip.isLinkLocal) return true;
if (ip.type == InternetAddressType.IPv4) {
final parts = ip.address.split('.').map(int.parse).toList();
if (parts[0] == 10) return true; // 10.0.0.0/8
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] == 192 && parts[1] == 168) return true; // 192.168.0.0/16
if (parts[0] == 169 && parts[1] == 254) return true; // metadata/link-local
if (parts[0] == 0) return true;
}
return false;
}
/// Handler for the Xtream Proxy
class ProxyHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final http.Client _client = http.Client();
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
static const _cacheDuration = Duration(minutes: 5);
static const _allowedHeaders = [
'content-type',
'content-range',
'accept-ranges',
'cache-control',
'server',
'date',
];
static const _allowedRequestHeaders = [
'user-agent',
'accept',
'range',
'referer',
];
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
// Basic caching to avoid DB overhead on every video segment
final now = DateTime.now();
const cacheKey =
'global_playlist'; // Currently app has one primary playlist per user/global
if (_playlistCache.containsKey(cacheKey)) {
final (cached, expiry) = _playlistCache[cacheKey]!;
if (now.isBefore(expiry)) return cached;
}
final playlist = await _getPlaylist(request);
if (playlist != null) {
_playlistCache[cacheKey] = (playlist, now.add(_cacheDuration));
}
return playlist;
}
ProxyHandler(this._getPlaylist);
/// Create Xtream proxy handler with M3U8 URL rewriting support
Handler get handler {
return (Request request) async {
final path = request.url.path;
// Only handle /api/xtream/* requests
// CRITICAL: Check path BEFORE anything else so non-proxy requests fall through to static handler
if (!path.startsWith('api/xtream/')) {
// Return 404 so Cascade tries next handler (streamingRouter)
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.
Uri? targetUrl;
// === PROXY LOGIC ===
try {
// Extract target URL from request
// Format: /api/xtream/http://server:port/path
String apiPath = path.substring('api/xtream/'.length);
// Decode URL if it's encoded
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
apiPath = Uri.decodeComponent(apiPath);
}
if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) {
return Response.badRequest(
body: 'Invalid API URL. Expected format: /api/xtream/http://...',
);
}
String fullUrl = apiPath;
if (request.url.query.isNotEmpty) {
if (fullUrl.contains('?')) {
fullUrl = '$fullUrl&${request.url.query}';
} else {
fullUrl = '$fullUrl?${request.url.query}';
}
}
targetUrl = Uri.parse(fullUrl);
// Only plain http(s) may be proxied
if (targetUrl.scheme != 'http' && targetUrl.scheme != 'https') {
return Response.forbidden('Unsupported URL scheme');
}
// Never proxy to loopback/private/link-local targets (SSRF)
if (isForbiddenProxyHost(targetUrl.host)) {
print('[Proxy] Blocked SSRF attempt to private host: ${targetUrl.host}');
return Response.forbidden('Access to this host is forbidden');
}
// SSRF Protection - but allow images/static assets from any host
// Xtream providers often use separate CDN servers for picons/images
final isStaticAsset = targetUrl.path.endsWith('.png') ||
targetUrl.path.endsWith('.jpg') ||
targetUrl.path.endsWith('.jpeg') ||
targetUrl.path.endsWith('.gif') ||
targetUrl.path.endsWith('.webp') ||
targetUrl.path.endsWith('.ico') ||
targetUrl.path.contains('/picons/') ||
targetUrl.path.contains('/logos/');
if (!isStaticAsset) {
// For API calls, enforce domain allowlist
final playlist = await _getCachedPlaylist(request);
if (playlist == null) {
return Response.forbidden(
'No active playlist configuration found to validate request',
);
}
final targetHost = targetUrl.host.toLowerCase();
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
if (targetHost != allowedHost) {
print(
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
);
return Response.forbidden(
'Access to this domain is forbidden by policy',
);
}
}
final proxyHeaders = <String, String>{
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
'Accept': '*/*',
'Accept-Encoding': 'identity',
'Keep-Alive': 'timeout=30, max=100', // Request persistent connection
};
// Forward Range header if present
if (request.headers['range'] != null) {
proxyHeaders['range'] = request.headers['range']!;
}
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) {
if (request.headers.containsKey(header)) {
proxyHeaders[header] = request.headers[header]!;
}
}
proxyRequest.headers.addAll(proxyHeaders);
proxyRequest.followRedirects = true;
if (request.method == 'POST') {
final bodyBytes = await request.read().toList();
proxyRequest.bodyBytes = 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));
// Build response headers from source response
final responseHeaders = <String, String>{
'access-control-allow-origin': '*',
'connection': 'keep-alive',
'keep-alive': 'timeout=30',
};
// Forward specific safe headers
for (final header in _allowedHeaders) {
if (response.headers.containsKey(header)) {
responseHeaders[header] = response.headers[header]!;
}
}
// ALWAYS stream the response for maximum performance and to avoid memory issues with large JSONs
return Response(
response.statusCode,
body: response.stream,
headers: responseHeaders,
);
} catch (e) {
rethrow;
}
} catch (e) {
print('[ProxyHandler] error on $path: $e');
// Return transparent 1x1 pixel image fallback for images
if (targetUrl?.path.endsWith('.png') == true ||
targetUrl?.path.endsWith('.jpg') == true) {
return Response.ok(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
),
headers: {'content-type': 'image/png'},
);
}
return Response.internalServerError(
body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}),
headers: {'content-type': 'application/json'},
);
}
};
}
}
-152
View File
@@ -1,152 +0,0 @@
import 'dart:io';
import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import '../database/database.dart';
import '../models/user.dart';
import '../services/recording_scheduler.dart';
import '../utils/safe_path.dart';
class RecordingsApi {
final AppDatabase _db;
final RecordingScheduler _scheduler;
RecordingsApi(this._db, this._scheduler);
/// 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'},
);
}
if (recording.filePath == null) {
return Response.notFound(
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
headers: {'Content-Type': 'application/json'},
);
}
// Les enregistrements sont écrits en .mkv avec un .log à côté
// (l'ancien replaceAll('.mp4', '.log') ne trouvait jamais le fichier).
final logFilePath = p.setExtension(recording.filePath!, '.log');
// 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'},
);
}
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'},
);
}
final logs = await logFile.readAsString();
return Response.ok(
json.encode({'logs': logs}),
headers: {'Content-Type': 'application/json'},
);
}
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (tous les enregistrements pour un admin)
Response handleGetAll(Request request) {
final user = request.context['user'] as User?;
final recordings = (user != null && !user.isAdmin)
? _db.getUserRecordings(user.id)
: _db.getAllRecordings();
return Response.ok(
json.encode(recordings.map((r) => r.toMap()).toList()),
headers: {'Content-Type': 'application/json'},
);
}
/// 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 userId = request.context['userId'] as String? ?? 'dev_user_id';
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']),
);
return Response.ok(
recording.toJson(),
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'},
);
}
}
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
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'},
);
}
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
await _scheduler.stopRecording(id);
_db.deleteRecording(id);
return Response.ok(
json.encode({'message': 'Enregistrement supprimé avec succès'}),
headers: {'Content-Type': 'application/json'},
);
}
/// 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'},
);
}
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'},
);
}
}
}
-116
View File
@@ -1,116 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import '../database/database.dart';
import '../models/user.dart';
/// API Season Passes — enregistrements répétés par titre d'émission
class SeasonPassesApi {
final AppDatabase _db;
SeasonPassesApi(this._db);
/// GET /api/season-passes — liste tous les season passes
Response handleGetAll(Request request) {
try {
final passes = _db.getAllSeasonPasses();
return Response.ok(
json.encode(passes),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/season-passes — créer un season pass
Future<Response> handlePost(Request request) async {
try {
final body = await request.readAsString();
final data = json.decode(body) as Map<String, dynamic>;
final showTitle = data['show_title'] as String?;
final channelId = data['channel_id'] as String?;
final streamUrl = data['stream_url'] as String?;
if (showTitle == null || showTitle.isEmpty) {
return Response(
400,
body: json.encode({'error': 'show_title est requis'}),
headers: {'Content-Type': 'application/json'},
);
}
if (channelId == null || channelId.isEmpty) {
return Response(
400,
body: json.encode({'error': 'channel_id est requis'}),
headers: {'Content-Type': 'application/json'},
);
}
if (streamUrl == null || streamUrl.isEmpty) {
return Response(
400,
body: json.encode({'error': 'stream_url est requis'}),
headers: {'Content-Type': 'application/json'},
);
}
// Récupérer l'utilisateur depuis le contexte
final user = request.context['user'] as User?;
final userId = user?.id ?? 'admin'; // fallback
// Vérifier si un season pass identique existe déjà
final existing = _db.getAllSeasonPasses();
final duplicate = existing.any(
(p) =>
(p['show_title'] as String).toLowerCase() ==
showTitle.toLowerCase() &&
p['channel_id'] == channelId,
);
if (duplicate) {
return Response(
409,
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
headers: {'Content-Type': 'application/json'},
);
}
final pass = _db.createSeasonPass(
userId: userId,
showTitle: showTitle,
channelId: channelId,
streamUrl: streamUrl,
);
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
return Response(
201,
body: json.encode(pass),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/season-passes/<id> — supprimer un season pass
Response handleDelete(Request request, String id) {
try {
_db.deleteSeasonPass(id);
return Response.ok(
json.encode({'message': 'Season Pass supprimé'}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': 'Erreur: $e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-63
View File
@@ -1,63 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class SettingsHandler {
final AppDatabase db;
SettingsHandler(this.db);
Router get router {
final router = Router();
// GET /api/settings
router.get('/', (Request req) {
// Get userId from context (set by auth middleware)
final userId = req.context['userId'] as String?;
if (userId == null) {
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
}
final settingsJson = db.getUserSettings(userId);
// If no settings exist, return empty object or default
return Response.ok(
settingsJson ?? '{}',
headers: {'content-type': 'application/json'},
);
});
// POST /api/settings
router.post('/', (Request req) async {
// Get userId from context (set by auth middleware)
final userId = req.context['userId'] as String?;
if (userId == null) {
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
}
try {
final body = await req.readAsString();
// Validate JSON
try {
jsonDecode(body);
} catch (e) {
return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'}));
}
db.updateUserSettings(userId, body);
return Response.ok(
body,
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'error': 'Failed to save settings: $e'}),
);
}
});
return router;
}
}
-129
View File
@@ -1,129 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
import '../services/simple_recorder.dart';
/// 🎯 Simple Recording API - 3 endpoints only!
class SimpleRecordingApi {
final AppDatabase db;
final SimpleRecorder recorder;
SimpleRecordingApi(this.db, this.recorder);
/// Setup routes
Router get router {
final router = Router();
router.post('/record/now', _recordNow);
router.post('/record/schedule', _scheduleRecord);
router.post('/record/stop/<channelId>', _stopRecord);
router.get('/record/list', _listRecordings);
router.get('/record/active', _getActive);
return router;
}
/// 🟢 POST /record/now
/// Start recording RIGHT NOW
/// Body: { channel_id, stream_url, title, duration_minutes }
Future<Response> _recordNow(Request request) async {
try {
final data = jsonDecode(await request.readAsString());
final id = await recorder.startRecording(
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Recording',
duration: Duration(minutes: data['duration_minutes'] ?? 60),
);
return Response.ok(
jsonEncode({
'status': 'recording',
'id': id,
'message': 'Recording started!',
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 🔵 POST /record/schedule
/// Schedule recording for later
/// Body: { channel_id, stream_url, title, start_time (ISO), end_time (ISO) }
Future<Response> _scheduleRecord(Request request) async {
try {
final data = jsonDecode(await request.readAsString());
final id = await recorder.scheduleRecording(
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Recording',
startTime: DateTime.parse(data['start_time']),
endTime: DateTime.parse(data['end_time']),
);
return Response.ok(
jsonEncode({
'status': 'scheduled',
'id': id,
'message': 'Recording scheduled!',
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 🔴 POST /record/stop/<channelId>
/// Stop recording immediately
Future<Response> _stopRecord(Request request, String channelId) async {
try {
await recorder.stopRecording(channelId);
return Response.ok(
jsonEncode({'status': 'stopped', 'message': 'Recording stopped!'}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 📋 GET /record/list
/// List all recordings
Response _listRecordings(Request request) {
final recordings = db.getAllRecordings();
return Response.ok(
jsonEncode({
'total': recordings.length,
'recordings': recordings.map((r) => r.toMap()).toList(),
}),
headers: {'Content-Type': 'application/json'},
);
}
/// 🟢 GET /record/active
/// List currently recording
Response _getActive(Request request) {
return Response.ok(
jsonEncode({
'active': recorder.getActive(),
'count': recorder.getActive().length,
}),
headers: {'Content-Type': 'application/json'},
);
}
}
-629
View File
@@ -1,629 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:http/http.dart' as http;
import '../database/database.dart';
import '../models/playlist_config.dart';
import '../services/ffmpeg_session_manager.dart';
import '../utils/log_redactor.dart';
/// Directory for temporary HLS segments
final Directory _hlsTempDir =
Directory('${Directory.systemTemp.path}/xtremflow_streams');
/// Global FFmpeg session registry (initialized in [initStreaming]).
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
String _getFFmpegPath() {
if (Platform.isWindows) {
if (File('ffmpeg.exe').existsSync()) return 'ffmpeg.exe';
if (File('bin/ffmpeg.exe').existsSync()) return 'bin/ffmpeg.exe';
if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) {
return 'ffmpeg/bin/ffmpeg.exe';
}
}
return 'ffmpeg'; // Default to PATH
}
/// Check if NVIDIA GPU acceleration is enabled via environment variable
bool _isNvidiaGpuEnabled() {
final envValue = Platform.environment['NVIDIA_GPU'] ??
Platform.environment['nvidia_gpu'] ??
'false';
return envValue.toLowerCase() == 'true' || envValue == '1';
}
/// Initialize streaming subsystem
Future<void> initStreaming() async {
await sessionManager.init();
}
/// Supported quality presets. `source` skips video transcoding entirely
/// (`-c:v copy`) — a huge CPU win since most Xtream streams are already
/// H.264. Audio is always normalized to AAC for HLS compatibility.
const supportedQualities = {'source', 'high', 'medium', 'low'};
String _sanitizeQuality(String? quality) {
return supportedQualities.contains(quality) ? quality! : 'high';
}
bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id);
/// Video encoding args for the requested quality (live profile).
List<String> _liveVideoArgs(String quality, bool gpu) {
switch (quality) {
case 'source':
return ['-c:v', 'copy'];
case 'medium':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k',
'-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p',
'-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k',
'-pix_fmt', 'yuv420p', '-g', '50',
];
case 'low':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4',
'-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '50',
];
case 'high':
default:
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k',
'-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p',
'-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-pix_fmt', 'yuv420p', '-g', '50',
];
}
}
/// Video encoding args for the requested quality (VOD profile).
List<String> _vodVideoArgs(String quality, bool gpu) {
switch (quality) {
case 'source':
return ['-c:v', 'copy'];
case 'medium':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-rc', 'cbr', '-b:v', '2500k', '-maxrate', '3000k',
'-bufsize', '5000k', '-g', '48', '-bf', '2',
'-pix_fmt', 'yuv420p',
]
: [
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-maxrate', '6000k', '-bufsize', '12000k',
'-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
case 'low':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4',
'-rc', 'cbr', '-b:v', '1500k', '-maxrate', '2000k',
'-bufsize', '3000k', '-vf', 'scale=-2:720',
'-g', '48', '-pix_fmt', 'yuv420p',
]
: [
'-c:v', 'libx264', '-preset', 'fast', '-crf', '26',
'-maxrate', '3000k', '-bufsize', '6000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
case 'high':
default:
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-rc', 'cbr', '-b:v', '4000k', '-maxrate', '4500k',
'-bufsize', '8000k', '-g', '48', '-bf', '2',
'-pix_fmt', 'yuv420p',
]
: [
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-maxrate', '12000k', '-bufsize', '24000k',
'-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
}
}
/// Audio args. Source mode keeps it simple (plain AAC); transcoded modes
/// keep the downmix + loudness normalization filter.
List<String> _audioArgs({required bool withFilters}) {
return [
'-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
if (withFilters)
...['-af',
'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15']
else
...['-af', 'aresample=async=1'],
];
}
Map<String, String> _hlsHeaders() => {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
};
Map<String, String> _segmentHeaders({int maxAge = 60}) => {
'Content-Type': 'video/mp2t',
'Cache-Control': 'max-age=$maxAge',
};
// ==========================================
// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy)
// ==========================================
/// Connexion amont ouverte, avec son client pour pouvoir le refermer.
class _LiveUpstream {
final http.Client client;
final http.StreamedResponse response;
_LiveUpstream(this.client, this.response);
}
Future<_LiveUpstream?> _openLiveUpstream(String targetUrl) async {
final client = http.Client();
try {
final request = http.Request('GET', Uri.parse(targetUrl))
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
..headers['Accept'] = '*/*';
final response = await client.send(request);
return _LiveUpstream(client, response);
} catch (_) {
client.close();
return null;
}
}
/// Nombre de reconnexions consécutives tentées avant d'abandonner le flux.
const _liveProxyMaxRetries = 4;
/// Corps du proxy `.ts` direct, avec reconnexion amont.
///
/// Les panneaux Xtream ferment régulièrement la connexion en cours de route
/// (bascule de source, limite de connexions simultanées, recyclage nginx).
/// Sans reprise, le `MediaSource` du navigateur reçoit un `sourceEnded` et la
/// lecture s'arrête net — le symptôme « ça coupe au bout de 30 s ».
/// On rouvre donc la source tant que le client, lui, écoute toujours.
///
/// Le compteur de tentatives est remis à zéro dès qu'une connexion a duré
/// assez longtemps pour être considérée saine : une coupure toutes les
/// 10 minutes ne doit pas finir par épuiser le quota.
Stream<List<int>> _resilientLiveBody(
String streamId,
String targetUrl,
_LiveUpstream first,
) async* {
var upstream = first;
var attempt = 0;
while (true) {
final startedAt = DateTime.now();
var bytes = 0;
try {
await for (final chunk in upstream.response.stream) {
bytes += chunk.length;
yield chunk;
}
} catch (e) {
print('[Live Proxy] $streamId : coupure amont ($e)');
} finally {
upstream.client.close();
}
final lasted = DateTime.now().difference(startedAt);
if (lasted > const Duration(seconds: 60) && bytes > 0) {
attempt = 0;
}
print(
'[Live Proxy] $streamId : flux terminé après ${lasted.inSeconds}s '
'($bytes octets)',
);
// Rouvrir la source, en réessayant tant qu'il reste du quota.
_LiveUpstream? next;
while (next == null && attempt < _liveProxyMaxRetries) {
attempt++;
// Laisser le panneau libérer le slot de connexion précédent : sur les
// comptes limités à une connexion simultanée, rouvrir immédiatement se
// fait refuser.
await Future<void>.delayed(const Duration(seconds: 1));
final candidate = await _openLiveUpstream(targetUrl);
if (candidate != null && candidate.response.statusCode == 200) {
next = candidate;
} else {
candidate?.client.close();
print(
'[Live Proxy] $streamId : reconnexion '
'$attempt/$_liveProxyMaxRetries échouée',
);
}
}
if (next == null) {
print('[Live Proxy] $streamId : abandon, source injoignable');
return;
}
upstream = next;
}
}
Handler createLiveStreamHandler(
Future<PlaylistConfig?> Function(Request) getPlaylist, {
bool Function()? isGpuEnabled,
}) {
final router = Router();
Future<Response> servePlaylist(
Request request,
String streamId,
String quality,
) 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';
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final sessionId = 'live_${streamId}_$quality';
if (!sessionManager.contains(sessionId)) {
print(
'[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}',
);
}
final session = await sessionManager.getOrStart(
id: sessionId,
isLive: true,
ffmpegPath: _getFFmpegPath(),
argsBuilder: (dir) => [
'-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
'-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',
// Abort reads stuck for 30s so a stalled upstream triggers the
// reconnect logic instead of wedging the process forever.
'-rw_timeout', '30000000',
'-i', targetUrl,
..._liveVideoArgs(quality, useNvidiaGpu),
..._audioArgs(withFilters: false),
// HLS sliding window: 10 x 2s segments (lower live latency than the
// previous 20-segment window while still safe for iOS).
// hls_init_time 1: first segment closes after ~1s so playback can
// start sooner; subsequent segments use hls_time.
'-f', 'hls',
'-hls_init_time', '1',
'-hls_time', '2',
'-hls_list_size', '10',
'-hls_flags', 'delete_segments+independent_segments',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', 'seg_%03d.ts',
'playlist.m3u8',
],
);
final result = await sessionManager.waitForPlaylist(session);
if (!result.ready) {
sessionManager.killSession(sessionId);
return Response(502, body: 'Live transcoder failed: ${result.error}');
}
session.touch();
return Response.ok(
File('${session.dir.path}/playlist.m3u8').openRead(),
headers: _hlsHeaders(),
);
}
// Route: /api/live/{streamId}/{quality}/playlist.m3u8
router.get('/<streamId>/<quality>/playlist.m3u8',
(Request request, String streamId, String quality) {
return servePlaylist(request, streamId, _sanitizeQuality(quality));
});
// Back-compat route: /api/live/{streamId}/playlist.m3u8 (?quality=...)
// Redirects so relative segment URLs resolve inside the quality path.
router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final quality =
_sanitizeQuality(request.url.queryParameters['quality']);
return Response.found('/api/live/$streamId/$quality/playlist.m3u8');
});
// 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);
if (playlist == null) return Response.forbidden('No playlist');
final targetUrl =
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
print(
'[Live Proxy] Forwarding $streamId: ${LogRedactor.redactUrl(targetUrl)}');
// Première connexion faite hors du générateur : elle seule décide du code
// de réponse renvoyé au client.
final first = await _openLiveUpstream(targetUrl);
if (first == null) {
return Response(502, body: 'Upstream connection failed');
}
if (first.response.statusCode != 200) {
first.client.close();
return Response(first.response.statusCode, body: 'Upstream refused');
}
return Response(
200,
body: _resilientLiveBody(streamId, targetUrl, first),
headers: {
'Content-Type': 'video/mp2t',
'Connection': 'keep-alive',
},
);
});
// Route: /api/live/{streamId}/{quality}/{segment}
router.get('/<streamId>/<quality>/<segment>',
(Request request, String streamId, String quality, String segment) {
if (!_isValidStreamId(streamId) || segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'live_${streamId}_${_sanitizeQuality(quality)}';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: _segmentHeaders());
});
return router.call;
}
// ==========================================
// 2. VOD HANDLER (FFmpeg HLS Transcoding)
// ==========================================
Handler createVodStreamHandler(
Future<PlaylistConfig?> Function(Request) getPlaylist, {
bool Function()? isGpuEnabled,
}) {
final router = Router();
Future<Response> servePlaylist(
Request request,
String streamId,
String quality,
) async {
if (!_isValidStreamId(streamId)) {
return Response.badRequest(body: 'Invalid stream ID');
}
final playlist = await getPlaylist(request);
if (playlist == null) {
return Response.internalServerError(body: 'No playlist');
}
// ?type=series or ?type=movie selects the upstream path
final contentType = request.url.queryParameters['type'] ?? 'movie';
final basePath = contentType == 'series' ? 'series' : 'movie';
final targetUrl =
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final sessionId = 'vod_${streamId}_$quality';
if (!sessionManager.contains(sessionId)) {
print(
'[VOD] Starting $sessionId ($contentType): ${LogRedactor.redactUrl(targetUrl)}',
);
}
final session = await sessionManager.getOrStart(
id: sessionId,
isLive: false,
ffmpegPath: _getFFmpegPath(),
argsBuilder: (dir) => [
'-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '10',
'-rw_timeout', '30000000',
'-timeout', '30000000',
'-analyzeduration', '5000000',
'-probesize', '10000000',
'-i', targetUrl,
..._vodVideoArgs(quality, useNvidiaGpu),
..._audioArgs(withFilters: quality != 'source'),
'-f', 'hls',
'-hls_time', '4',
'-hls_list_size', '0',
'-hls_playlist_type', 'event',
'-hls_allow_cache', '1',
'-hls_flags', 'independent_segments',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', 'segment_%03d.ts',
'-start_number', '0',
'playlist.m3u8',
],
);
final result = await sessionManager.waitForPlaylist(session);
if (!result.ready) {
sessionManager.killSession(sessionId);
return Response(502, body: 'VOD transcoder failed: ${result.error}');
}
session.touch();
return Response.ok(
File('${session.dir.path}/playlist.m3u8').openRead(),
headers: _hlsHeaders(),
);
}
// Route: /api/vod/{streamId}/{quality}/playlist.m3u8
router.get('/<streamId>/<quality>/playlist.m3u8',
(Request request, String streamId, String quality) {
return servePlaylist(request, streamId, _sanitizeQuality(quality));
});
// Back-compat route: /api/vod/{streamId}/playlist.m3u8 (?quality=...)
router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final quality =
_sanitizeQuality(request.url.queryParameters['quality']);
final query = request.url.queryParameters['type'] != null
? '?type=${request.url.queryParameters['type']}'
: '';
return Response.found('/api/vod/$streamId/$quality/playlist.m3u8$query');
});
// Route: /api/vod/{streamId}/{quality}/{segment}
router.get('/<streamId>/<quality>/<segment>',
(Request request, String streamId, String quality, String segment) {
if (!_isValidStreamId(streamId) || segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'vod_${streamId}_${_sanitizeQuality(quality)}';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
});
return router.call;
}
// ==========================================
// 3. RECORDING HANDLER (FFmpeg HLS Transcoding)
// ==========================================
Handler createRecordingStreamHandler(
AppDatabase db, {
bool Function()? isGpuEnabled,
}) {
final router = Router();
router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final recording = db.getRecordingById(streamId);
if (recording == null) {
return Response.notFound('Recording not found');
}
// Check recording status - only allow streaming if completed or recording
if (recording.status == 'scheduled') {
return Response(202, body: 'Recording not yet started');
}
if (recording.status == 'failed') {
return Response(422,
body: 'Recording failed: ${recording.errorReason ?? 'unknown error'}');
}
if (recording.status != 'completed' && recording.status != 'recording') {
return Response.internalServerError(
body: 'Invalid recording status: ${recording.status}');
}
if (recording.filePath == null) {
return Response.internalServerError(body: 'Recording file path not set');
}
final targetUrl = recording.filePath!;
if (!File(targetUrl).existsSync()) {
return Response.internalServerError(
body: 'Physical recording file not found');
}
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final sessionId = 'rec_$streamId';
final session = await sessionManager.getOrStart(
id: sessionId,
isLive: false,
ffmpegPath: _getFFmpegPath(),
argsBuilder: (dir) => [
'-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
'-i', targetUrl,
if (useNvidiaGpu) ...[
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
'-bufsize', '6000k',
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
] else ...[
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
'-g', '48', '-threads', '0',
],
..._audioArgs(withFilters: true),
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
'-hls_playlist_type', 'vod', '-hls_allow_cache', '1',
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
'playlist.m3u8',
],
);
final result = await sessionManager.waitForPlaylist(session);
if (!result.ready) {
sessionManager.killSession(sessionId);
return Response(502,
body: 'Recording transcoder failed: ${result.error}');
}
session.touch();
return Response.ok(
File('${session.dir.path}/playlist.m3u8').openRead(),
headers: _hlsHeaders(),
);
});
router.get('/<streamId>/<segment>',
(Request request, String streamId, String segment) async {
if (segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'rec_$streamId';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
});
return router.call;
}
-166
View File
@@ -1,166 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class UsersHandler {
final AppDatabase db;
UsersHandler(this.db);
Router get router {
final router = Router();
router.get('/', _getAllUsers);
router.post('/', _createUser);
router.put('/<id>/password', _updatePassword);
router.put('/<id>', _updateUser);
router.delete('/<id>', _deleteUser);
return router;
}
/// Check if requester is admin
bool _isAdmin(Request request) {
final userId = request.context['userId'] as String?;
if (userId == null) return false;
final user = db.findUserById(userId);
return user?.isAdmin ?? false;
}
/// GET /api/users
Future<Response> _getAllUsers(Request request) async {
try {
if (!_isAdmin(request)) {
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
}
final users = db.getAllUsers();
return Response.ok(jsonEncode({
'success': true,
'users': users.map((u) => u.toJson()).toList(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/users
Future<Response> _createUser(Request request) async {
try {
if (!_isAdmin(request)) {
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final username = payload['username'] as String?;
final password = payload['password'] as String?;
final isAdmin = payload['isAdmin'] as bool? ?? false;
if (username == null || password == null) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Missing required fields',
}), headers: {'Content-Type': 'application/json'},);
}
if (db.findUserByUsername(username) != null) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Username already exists',
}), headers: {'Content-Type': 'application/json'},);
}
final user = db.createUser(username, password, isAdmin: isAdmin);
return Response.ok(jsonEncode({
'success': true,
'user': user.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// PUT /api/users/:id/password
Future<Response> _updatePassword(Request request, String id) async {
try {
if (!_isAdmin(request)) {
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final password = payload['password'] as String?;
if (password == null) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Missing password',
}), headers: {'Content-Type': 'application/json'},);
}
db.updateUserPassword(id, password);
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// PUT /api/users/:id (Toggle Admin)
Future<Response> _updateUser(Request request, String id) async {
try {
if (!_isAdmin(request)) {
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final isAdmin = payload['isAdmin'] as bool?;
if (isAdmin != null) {
db.updateUserAdminStatus(id, isAdmin);
}
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/users/:id
Future<Response> _deleteUser(Request request, String id) async {
try {
if (!_isAdmin(request)) {
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
}
// Prevent deleting self
final currentUserId = request.context['userId'] as String;
if (currentUserId == id) {
return Response.badRequest(body: jsonEncode({
'success': false,
'error': 'Cannot delete yourself',
}), headers: {'Content-Type': 'application/json'},);
}
db.deleteUser(id);
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-78
View File
@@ -1,78 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
/// Authenticated gateway to the Xtream `player_api.php` endpoint.
///
/// The frontend never sees the Xtream credentials: it calls
/// `GET /api/xtream-api?action=...` with its session token and the server
/// injects the username/password of the user's playlist before forwarding.
class XtreamApiHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final http.Client _client = http.Client();
XtreamApiHandler(this._getPlaylist);
/// Query parameters the client is allowed to pass through to Xtream.
static const _allowedParams = {
'action',
'category_id',
'stream_id',
'series_id',
'vod_id',
'limit',
'type',
};
Future<Response> handle(Request request) async {
final playlist = await _getPlaylist(request);
if (playlist == null) {
return Response.forbidden(
jsonEncode({'error': 'No playlist configured'}),
headers: {'Content-Type': 'application/json'},
);
}
final params = <String, String>{
'username': playlist.username,
'password': playlist.password,
};
request.url.queryParameters.forEach((key, value) {
if (_allowedParams.contains(key)) params[key] = value;
});
final base = Uri.parse('${playlist.dns}/player_api.php');
final targetUrl = base.replace(queryParameters: params);
try {
final proxyRequest = http.Request('GET', targetUrl)
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
..headers['Accept'] = '*/*'
..followRedirects = true;
final response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
return Response(
response.statusCode,
body: response.stream,
headers: {
'Content-Type':
response.headers['content-type'] ?? 'application/json',
'Cache-Control': 'no-store',
},
);
} catch (e) {
print(
'[XtreamApi] Error forwarding to ${LogRedactor.redactUrl(targetUrl.toString())}: $e',
);
return Response.internalServerError(
body: jsonEncode({'error': 'Upstream Xtream API error'}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-616
View File
@@ -1,616 +0,0 @@
import 'dart:io';
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import '../models/user.dart';
import '../models/playlist.dart';
import '../models/session.dart' as models;
import '../models/recording.dart';
import '../utils/password_hasher.dart';
class AppDatabase {
late final Database _db;
final _uuid = const Uuid();
/// Initialize database and create tables
Future<void> init() async {
const dbPath = '/app/data/xtremflow.db';
// Ensure data directory exists
final dir = Directory('/app/data');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
_db = sqlite3.open(dbPath);
await _createTables();
print('Database initialized: $dbPath');
}
/// Create database tables
Future<void> _createTables() async {
// Users table
_db.execute('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''');
// Playlists table
_db.execute('''
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
dns TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''');
// Sessions table
_db.execute('''
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''');
// User Settings table
_db.execute('''
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''');
// TV Recordings table
_db.execute('''
CREATE TABLE IF NOT EXISTS tv_recordings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
title TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
file_path TEXT,
error_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''');
// Season Passes table (enregistrements répétés intelligents)
_db.execute('''
CREATE TABLE IF NOT EXISTS season_passes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
show_title TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''');
// Indexes
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)',
);
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
);
}
/// Seed default admin user if no users exist
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');
_db.execute(
'''
INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, 1)
''',
[adminId, 'admin', passwordHash],
);
print('Default admin user created (username: admin, password: admin)');
}
}
// ==================== Users ====================
/// Find user by username
User? findUserByUsername(String username) {
final result = _db.select(
'SELECT * FROM users WHERE username = ?',
[username],
);
if (result.isEmpty) return null;
return User.fromMap(result.first);
}
/// Find user by ID
User? findUserById(String userId) {
final result = _db.select(
'SELECT * FROM users WHERE id = ?',
[userId],
);
if (result.isEmpty) return null;
return User.fromMap(result.first);
}
/// Verify user credentials
User? verifyCredentials(String username, String password) {
final result = _db.select(
'SELECT * FROM users WHERE username = ?',
[username],
);
if (result.isEmpty) return null;
final passwordHash = result.first['password_hash'] as String;
if (!PasswordHasher.verify(password, passwordHash)) {
return null;
}
// Lazy migration: rehash legacy SHA-256 hashes with bcrypt on
// successful login (the only moment the plaintext is available).
if (PasswordHasher.isLegacy(passwordHash)) {
updatePasswordHash(
result.first['id'] as String,
PasswordHasher.hash(password),
);
print('[Auth] Migrated legacy password hash for user: $username');
}
return User.fromMap(result.first);
}
/// Replace a user's stored password hash (used by lazy bcrypt migration)
void updatePasswordHash(String userId, String newHash) {
_db.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[newHash, userId],
);
}
/// Create new user
User createUser(String username, String password, {bool isAdmin = false}) {
final userId = _uuid.v4();
final passwordHash = PasswordHasher.hash(password);
_db.execute(
'''
INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, ?)
''',
[userId, username, passwordHash, isAdmin ? 1 : 0],
);
return User(
id: userId,
username: username,
isAdmin: isAdmin,
createdAt: DateTime.now(),
);
}
/// Get all users
List<User> getAllUsers() {
final result = _db.select('SELECT * FROM users ORDER BY username ASC');
return result.map((row) => User.fromMap(row)).toList();
}
/// Update user password
void updateUserPassword(String userId, String newPassword) {
final passwordHash = PasswordHasher.hash(newPassword);
_db.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[passwordHash, userId],
);
}
/// Update user administration status
void updateUserAdminStatus(String userId, bool isAdmin) {
_db.execute(
'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[isAdmin ? 1 : 0, userId],
);
}
/// Delete user
void deleteUser(String userId) {
_db.execute('DELETE FROM users WHERE id = ?', [userId]);
}
// ==================== Sessions ====================
/// Create new session
models.Session createSession(String userId, {Duration? duration}) {
final sessionId = _uuid.v4();
final token = _uuid.v4();
final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7));
_db.execute(
'''
INSERT INTO sessions (id, user_id, token, expires_at)
VALUES (?, ?, ?, ?)
''',
[sessionId, userId, token, expiresAt.toIso8601String()],
);
return models.Session(
id: sessionId,
userId: userId,
token: token,
expiresAt: expiresAt,
createdAt: DateTime.now(),
);
}
/// Find session by token
models.Session? findSessionByToken(String token) {
final result = _db.select(
'SELECT * FROM sessions WHERE token = ?',
[token],
);
if (result.isEmpty) return null;
final session = models.Session.fromMap(result.first);
// Check if expired
if (session.isExpired) {
deleteSession(token);
return null;
}
return session;
}
/// Delete session (logout)
void deleteSession(String token) {
_db.execute('DELETE FROM sessions WHERE token = ?', [token]);
}
/// Clean expired sessions
void cleanExpiredSessions() {
_db.execute(
'DELETE FROM sessions WHERE expires_at < ?',
[DateTime.now().toIso8601String()],
);
}
// ==================== Playlists ====================
/// Get all playlists for a user
List<Playlist> getPlaylists(String userId) {
final result = _db.select(
'SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC',
[userId],
);
return result.map((row) => Playlist.fromMap(row)).toList();
}
/// Get playlist by ID
Playlist? getPlaylistById(String playlistId) {
final result = _db.select(
'SELECT * FROM playlists WHERE id = ?',
[playlistId],
);
if (result.isEmpty) return null;
return Playlist.fromMap(result.first);
}
/// Create new playlist
Playlist createPlaylist({
required String userId,
required String name,
required String serverUrl,
required String username,
required String password,
String? dns,
}) {
final playlistId = _uuid.v4();
final now = DateTime.now().toIso8601String();
_db.execute(
'''
INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[playlistId, userId, name, serverUrl, username, password, dns, now, now],
);
return Playlist(
id: playlistId,
userId: userId,
name: name,
serverUrl: serverUrl,
username: username,
password: password,
dns: dns,
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
/// Update playlist
Playlist updatePlaylist({
required String playlistId,
required String name,
required String serverUrl,
required String username,
required String password,
String? dns,
}) {
final now = DateTime.now().toIso8601String();
_db.execute(
'''
UPDATE playlists
SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ?
WHERE id = ?
''',
[name, serverUrl, username, password, dns, now, playlistId],
);
return getPlaylistById(playlistId)!;
}
/// Delete playlist
void deletePlaylist(String playlistId) {
_db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]);
}
// ==================== User Settings ====================
/// Get user settings as JSON string
String? getUserSettings(String userId) {
final result = _db.select(
'SELECT settings_json FROM user_settings WHERE user_id = ?',
[userId],
);
if (result.isEmpty) return null;
return result.first['settings_json'] as String;
}
/// Update user settings
void updateUserSettings(String userId, String settingsJson) {
final now = DateTime.now().toIso8601String();
// UPSERT (Insert or Replace)
_db.execute(
'''
INSERT INTO user_settings (user_id, settings_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
settings_json = excluded.settings_json,
updated_at = excluded.updated_at
''',
[userId, settingsJson, now],
);
}
/// Check if NVIDIA GPU is enabled in any user's settings
/// This is a server-wide setting that applies to all streaming
bool isNvidiaGpuEnabled() {
try {
// Check all user settings for enable_nvidia_gpu
final result = _db.select(
'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1',
);
if (result.isEmpty) {
print('[GPU] No user settings found in database');
return false;
}
final settingsJson = result.first['settings_json'] as String;
print('[GPU] Settings found: $settingsJson');
// Parse JSON properly to check the setting
// Handle both "enable_nvidia_gpu":true and "enable_nvidia_gpu": true
final RegExp gpuRegex =
RegExp(r'"enable_nvidia_gpu"\s*:\s*true', caseSensitive: false);
final isEnabled = gpuRegex.hasMatch(settingsJson);
print('[GPU] NVIDIA GPU enabled: $isEnabled');
return isEnabled;
} catch (e) {
print('[GPU] Error checking GPU setting: $e');
}
return false;
}
// ==================== TV Recordings ====================
/// Créer un nouvel enregistrement
Recording createRecording({
required String userId,
required String channelId,
required String streamUrl,
required String title,
required DateTime startTime,
required DateTime endTime,
}) {
final recordingId = _uuid.v4();
final now = DateTime.now().toIso8601String();
_db.execute(
'''
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
recordingId,
userId,
channelId,
streamUrl,
title,
startTime.toIso8601String(),
endTime.toIso8601String(),
now,
now,
],
);
return Recording(
id: recordingId,
userId: userId,
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
status: 'scheduled',
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
/// Lister tous les enregistrements (pour le Scheduler et 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();
}
/// Lister les enregistrements d'un utilisateur spécifique
List<Recording> getUserRecordings(String userId) {
final result = _db.select(
'SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC',
[userId],
);
return result.map((row) => Recording.fromMap(row)).toList();
}
/// Récupérer un enregistrement par ID
Recording? getRecordingById(String id) {
final result = _db.select('SELECT * FROM tv_recordings WHERE id = ?', [id]);
if (result.isEmpty) return null;
return Recording.fromMap(result.first);
}
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
///
/// [clearError] efface le motif d'erreur au lieu de conserver l'ancien :
/// utile quand un enregistrement précédemment en échec est relancé.
void updateRecordingStatus(
String id,
String status, {
String? filePath,
String? errorReason,
bool clearError = false,
}) {
final now = DateTime.now().toIso8601String();
final errorExpr = clearError ? '?' : 'COALESCE(?, error_reason)';
_db.execute(
'''
UPDATE tv_recordings
SET status = ?, file_path = COALESCE(?, file_path), error_reason = $errorExpr, updated_at = ?
WHERE id = ?
''',
[status, filePath, errorReason, now, 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]);
}
// ==================== Season Passes ====================
/// Créer un Season Pass (enregistrement répété par titre d'émission)
Map<String, dynamic> createSeasonPass({
required String userId,
required String showTitle,
required String channelId,
required String streamUrl,
}) {
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],
);
return {
'id': id,
'user_id': userId,
'show_title': showTitle,
'channel_id': channelId,
'stream_url': streamUrl,
'enabled': 1,
'created_at': now,
};
}
/// Lister tous les Season Passes
List<Map<String, dynamic>> getAllSeasonPasses() {
final result = _db.select(
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
);
return result.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// 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)
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()],
);
return (result.first['cnt'] as int) > 0;
}
/// Close database connection
void close() {
_db.dispose();
}
}
-101
View File
@@ -1,101 +0,0 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
import '../database/database.dart';
/// Middleware to authenticate requests
Middleware authMiddleware(AppDatabase db) {
return (Handler handler) {
return (Request request) async {
// Skip auth for login endpoint
if (request.url.path.startsWith('api/auth/login')) {
return handler(request);
}
// Extract token
final token = _extractToken(request);
if (token == null) {
return Response(401, body: 'Unauthorized');
}
// Verify session (findSessionByToken enforces expires_at)
final session = db.findSessionByToken(token);
if (session == null) {
return Response(401, body: 'Invalid or expired session');
}
// Add user info to context. Both keys are populated because handlers
// are inconsistent: playlists_handler reads 'userId', while
// getPlaylist/admin routes read 'user'.
final user = db.findUserById(session.userId);
final updatedRequest = request.change(context: {
...request.context,
'userId': session.userId,
if (user != null) 'user': user,
},);
return handler(updatedRequest);
};
};
}
/// Auth middleware for streaming routes (HLS playlists/segments).
///
/// hls.js/mpegts.js inside the player iframe cannot send Authorization
/// headers, so these routes accept the HttpOnly session cookie instead.
/// Loopback requests without X-Forwarded-For are allowed through because
/// the recording scheduler's local FFmpeg fetches
/// `http://localhost:8089/api/live/<id>.ts` without credentials.
Middleware streamAuthMiddleware(AppDatabase db) {
return (Handler handler) {
return (Request request) async {
// Only guard actual streaming routes. Anything else must fall through
// untouched: the wrapped router returns 404 for unmatched paths and the
// Cascade then reaches the static file handler (a 401 here would block
// the whole app, e.g. GET / through the reverse proxy).
final path = request.url.path;
final isStreamingPath = path.startsWith('api/live') ||
path.startsWith('api/vod') ||
path.startsWith('api/recordings/stream');
if (!isStreamingPath) {
return handler(request);
}
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
final isLoopback = connectionInfo?.remoteAddress.isLoopback ?? false;
final viaProxy = request.headers.containsKey('x-forwarded-for');
if (isLoopback && !viaProxy) {
return handler(request);
}
final token = _extractToken(request);
if (token == null || db.findSessionByToken(token) == null) {
return Response(401, body: 'Unauthorized');
}
return handler(request);
};
};
}
/// Extract token from Authorization header or cookie
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
if (authHeader != null && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Try cookie
final cookie = request.headers['cookie'];
if (cookie != null) {
final parts = cookie.split(';');
for (final part in parts) {
final trimmed = part.trim();
if (trimmed.startsWith('session=')) {
return trimmed.substring(8);
}
}
}
return null;
}
-177
View File
@@ -1,177 +0,0 @@
import 'package:shelf/shelf.dart';
import 'dart:async';
import 'dart:io';
/// Security Middleware Collection
///
/// Includes:
/// - 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();
}
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
return connectionInfo?.remoteAddress.address ?? 'unknown';
}
/// 1. Security Headers Middleware
/// Adds standard security headers to every response.
Middleware securityHeadersMiddleware() {
return (Handler handler) {
return (Request request) async {
final response = await handler(request);
return response.change(headers: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN', // Allow embedding player.html iframes
'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Report-Only first: Flutter Web (CanvasKit/wasm) and google_fonts
// can break under an enforcing CSP. Promote to enforcing only after
// a clean soak with no violations in browser consoles.
'Content-Security-Policy-Report-Only':
"default-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob: https:; "
"media-src 'self' blob:; "
"connect-src 'self' https://fonts.gstatic.com",
},);
};
};
}
/// 2. Honeypot Middleware
/// Intercepts requests to common vulnerability scanning paths.
/// Returns a 403 Forbidden immediately and logs the incident.
Middleware honeypotMiddleware() {
// list of common bot targets
const honeypotPaths = [
'/admin/phpmyadmin',
'/phpmyadmin',
'/wp-admin',
'/wp-login.php',
'/.env',
'/config.php',
'/api/.env',
'/console',
'/actuator/health',
];
return (Handler handler) {
return (Request request) {
final path = request.url.path;
// Check if path contains any honeypot target
for (final trap in honeypotPaths) {
if (path.contains(trap.replaceAll('/', ''))) { // Simple check
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
return Response.forbidden('Access Denied');
}
}
return handler(request);
};
};
}
/// 3. Rate Limit Middleware (In-Memory)
/// Limits requests per IP address.
/// Default: 200 requests per minute per IP.
/// 600 plutôt que 200 : une grille de chaînes déclenche une requête de
/// logo et une de guide par tuile, et tous les clients partagent l'IP du
/// reverse proxy. Le plafond précédent coupait la grille en 429 au bout de
/// quelques écrans de défilement.
Middleware rateLimitMiddleware({int requestsPerMinute = 600}) {
final clientRequests = <String, List<DateTime>>{};
// Cleanup timer to remove old entries and prevent memory leaks
Timer.periodic(const Duration(minutes: 5), (_) {
final now = DateTime.now();
clientRequests.removeWhere((_, times) {
// Remove timestamps older than 1 minute
times.removeWhere((t) => now.difference(t).inMinutes > 1);
return times.isEmpty;
});
});
return (Handler handler) {
return (Request request) {
final clientIp = clientIpOf(request);
if (clientIp != 'unknown' && clientIp != '127.0.0.1') {
final now = DateTime.now();
// Get or create history for this IP
final history = clientRequests.putIfAbsent(clientIp, () => []);
// Clean old requests (older than 1 minute)
history.removeWhere((t) => now.difference(t).inMinutes >= 1);
// Check limit
if (history.length >= requestsPerMinute) {
print('SECURITY WARN: Rate limit exceeded for $clientIp');
return Response(429, body: 'Too Many Requests');
}
// Add current request
history.add(now);
}
return handler(request);
};
};
}
/// 4. Login Rate Limit Middleware
/// Strict per-IP limit on login attempts (brute-force protection),
/// plus a small delay on each attempt to slow credential stuffing.
Middleware loginRateLimitMiddleware({int attemptsPerMinute = 10}) {
final attempts = <String, List<DateTime>>{};
Timer.periodic(const Duration(minutes: 5), (_) {
final now = DateTime.now();
attempts.removeWhere((_, times) {
times.removeWhere((t) => now.difference(t).inMinutes > 1);
return times.isEmpty;
});
});
return (Handler handler) {
return (Request request) async {
// Only throttle the login POST; other auth routes pass through
if (!(request.method == 'POST' && request.url.path.endsWith('login'))) {
return handler(request);
}
final clientIp = clientIpOf(request);
final now = DateTime.now();
final history = attempts.putIfAbsent(clientIp, () => []);
history.removeWhere((t) => now.difference(t).inMinutes >= 1);
if (history.length >= attemptsPerMinute) {
print('SECURITY WARN: Login rate limit exceeded for $clientIp');
return Response(429, body: 'Too many login attempts. Try again later.');
}
history.add(now);
final response = await handler(request);
if (response.statusCode == 401) {
// Slow down brute-force attempts on failed logins
await Future.delayed(const Duration(milliseconds: 300));
}
return response;
};
};
}
-67
View File
@@ -1,67 +0,0 @@
class Playlist {
final String id;
final String userId;
final String name;
final String serverUrl;
final String username;
final String password;
final String? dns;
final DateTime createdAt;
final DateTime updatedAt;
Playlist({
required this.id,
required this.userId,
required this.name,
required this.serverUrl,
required this.username,
required this.password,
this.dns,
required this.createdAt,
required this.updatedAt,
});
factory Playlist.fromMap(Map<String, dynamic> map) {
return Playlist(
id: map['id'] as String,
userId: map['user_id'] as String,
name: map['name'] as String,
serverUrl: map['server_url'] as String,
username: map['username'] as String,
password: map['password'] as String,
dns: map['dns'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'name': name,
'serverUrl': serverUrl,
'username': username,
// Never expose the Xtream password to clients; the backend injects
// credentials server-side (see xtream_api_handler.dart).
'password': '',
'dns': dns,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'name': name,
'server_url': serverUrl,
'username': username,
'password': password,
'dns': dns,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
}
-52
View File
@@ -1,52 +0,0 @@
/// Pure Dart model for Xtream Codes playlist configuration
/// This is a server-side copy without Hive/Equatable dependencies
class PlaylistConfig {
final String id;
final String name;
/// Xtream server URL (without trailing slash)
final String dns;
final String username;
/// Xtream password
final String password;
final DateTime createdAt;
final bool isActive;
const PlaylistConfig({
required this.id,
required this.name,
required this.dns,
required this.username,
required this.password,
required this.createdAt,
this.isActive = true,
});
/// Creates a copy of this playlist with updated fields
PlaylistConfig copyWith({
String? id,
String? name,
String? dns,
String? username,
String? password,
DateTime? createdAt,
bool? isActive,
}) {
return PlaylistConfig(
id: id ?? this.id,
name: name ?? this.name,
dns: dns ?? this.dns,
username: username ?? this.username,
password: password ?? this.password,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
);
}
/// Generate Xtream API base URL
String get apiBaseUrl => '$dns/player_api.php';
}
-100
View File
@@ -1,100 +0,0 @@
import 'dart:convert';
class Recording {
final String id;
final String userId;
final String channelId;
final String streamUrl;
final String title;
final DateTime startTime;
final DateTime endTime;
final String status; // 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
final String? filePath;
final String? errorReason;
final DateTime createdAt;
final DateTime updatedAt;
Recording({
required this.id,
required this.userId,
required this.channelId,
required this.streamUrl,
required this.title,
required this.startTime,
required this.endTime,
required this.status,
this.filePath,
this.errorReason,
required this.createdAt,
required this.updatedAt,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'channel_id': channelId,
'stream_url': streamUrl,
'title': title,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'status': status,
'file_path': filePath,
'error_reason': errorReason,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
factory Recording.fromMap(Map<String, dynamic> map) {
return Recording(
id: map['id'] ?? '',
userId: map['user_id'] ?? '',
channelId: map['channel_id'] ?? '',
streamUrl: map['stream_url'] ?? '',
title: map['title'] ?? '',
startTime: DateTime.parse(map['start_time']),
endTime: DateTime.parse(map['end_time']),
status: map['status'] ?? 'scheduled',
filePath: map['file_path'],
errorReason: map['error_reason'],
createdAt: DateTime.parse(map['created_at']),
updatedAt: DateTime.parse(map['updated_at']),
);
}
String toJson() => json.encode(toMap());
factory Recording.fromJson(String source) =>
Recording.fromMap(json.decode(source));
Recording copyWith({
String? id,
String? userId,
String? channelId,
String? streamUrl,
String? title,
DateTime? startTime,
DateTime? endTime,
String? status,
String? filePath,
String? errorReason,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Recording(
id: id ?? this.id,
userId: userId ?? this.userId,
channelId: channelId ?? this.channelId,
streamUrl: streamUrl ?? this.streamUrl,
title: title ?? this.title,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
status: status ?? this.status,
filePath: filePath ?? this.filePath,
errorReason: errorReason ?? this.errorReason,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
-37
View File
@@ -1,37 +0,0 @@
class Session {
final String id;
final String userId;
final String token;
final DateTime expiresAt;
final DateTime createdAt;
Session({
required this.id,
required this.userId,
required this.token,
required this.expiresAt,
required this.createdAt,
});
factory Session.fromMap(Map<String, dynamic> map) {
return Session(
id: map['id'] as String,
userId: map['user_id'] as String,
token: map['token'] as String,
expiresAt: DateTime.parse(map['expires_at'] as String),
createdAt: DateTime.parse(map['created_at'] as String),
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'token': token,
'expires_at': expiresAt.toIso8601String(),
'created_at': createdAt.toIso8601String(),
};
}
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
-31
View File
@@ -1,31 +0,0 @@
class User {
final String id;
final String username;
final bool isAdmin;
final DateTime createdAt;
User({
required this.id,
required this.username,
required this.isAdmin,
required this.createdAt,
});
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as String,
username: map['username'] as String,
isAdmin: (map['is_admin'] as int) == 1,
createdAt: DateTime.parse(map['created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'isAdmin': isAdmin,
'createdAt': createdAt.toIso8601String(),
};
}
}
-25
View File
@@ -1,25 +0,0 @@
name: xtremflow_server
description: Backend server with API proxy
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
shelf: ^1.4.1
shelf_static: ^1.1.2
shelf_router: ^1.1.4
http: ^1.2.0
args: ^2.4.2
sqlite3: ^2.4.0
crypto: ^3.0.3
bcrypt: ^1.1.3
path: ^1.9.0
uuid: ^4.3.3
hive: ^2.2.3
equatable: ^2.0.5
xml: ^6.5.0
dev_dependencies:
test: ^1.25.0
-406
View File
@@ -1,406 +0,0 @@
import 'dart:io';
import 'dart:async';
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:args/args.dart';
import 'database/database.dart';
import 'models/user.dart';
import 'models/playlist.dart';
import 'models/playlist_config.dart';
import 'api/auth_handler.dart';
import 'api/users_handler.dart';
import 'api/playlists_handler.dart';
import 'api/settings_handler.dart';
import 'api/streaming_handler.dart';
import 'api/proxy_handler.dart';
import 'api/recordings_api.dart';
import 'api/epg_api.dart';
import 'services/xmltv_epg_service.dart';
import 'api/season_passes_api.dart';
import 'api/xtream_api_handler.dart';
import 'middleware/auth_middleware.dart';
import 'middleware/security_middleware.dart';
import 'services/cleanup_service.dart';
import 'services/recording_scheduler.dart';
void main(List<String> args) async {
// Parse command line arguments
final parser = ArgParser()
..addOption('port', abbr: 'p', defaultsTo: '8089')
..addOption('path', defaultsTo: '/app/web');
final result = parser.parse(args);
final port = int.parse(result['port']);
final webPath = result['path'];
// Initialize database
final db = AppDatabase();
await db.init();
await db.seedAdmin();
// Initialize and start Recording Scheduler
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();
// Helper to get playlist from request
Future<PlaylistConfig?> getPlaylist(Request request) async {
Playlist? playlist;
final user = request.context['user'] as User?;
// If we have a user from auth middleware, prefer their playlists
if (user != null) {
print('[getPlaylist] User from context: ${user.username}');
final playlists = db.getPlaylists(user.id);
if (playlists.isNotEmpty) playlist = playlists.first;
} else {
// Fallback for proxy: get first user's first playlist
print('[getPlaylist] No user in context, using fallback');
final users = db.getAllUsers();
print('[getPlaylist] Total users in DB: ${users.length}');
if (users.isNotEmpty) {
final playlists = db.getPlaylists(users[0].id);
print(
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists',
);
if (playlists.isNotEmpty) playlist = playlists.first;
}
}
if (playlist != null) {
print(
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
);
return PlaylistConfig(
id: playlist.id,
name: playlist.name,
dns: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
createdAt: playlist.createdAt,
isActive: true,
);
}
print('[getPlaylist] WARNING: No playlist found, returning null');
return null;
}
// Create API handlers
final authHandler = AuthHandler(db);
final playlistsHandler = PlaylistsHandler(db);
final usersHandler = UsersHandler(db);
final settingsHandler = SettingsHandler(db);
final proxyHandler = ProxyHandler(getPlaylist);
final recordingsApi = RecordingsApi(db, recordingScheduler);
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
// l'EPG se limite alors au panneau de l'abonné.
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
.split(',')
.map((u) => u.trim())
.where((u) => u.isNotEmpty)
.toList();
if (xmltvUrls.isEmpty) {
print('[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)');
} else {
print('[EPG] Repli XMLTV : ${xmltvUrls.length} source(s)');
}
final epgApi = EpgApi(
getPlaylist,
xmltv: xmltvUrls.isEmpty ? null : XmltvEpgService(sourceUrls: xmltvUrls),
);
final seasonPassesApi = SeasonPassesApi(db);
final xtreamApiHandler = XtreamApiHandler(getPlaylist);
// TV Recordings sub-router (wrapped with auth below).
// NOTE: /api/recordings/stream/* falls through this router (404) and is
// handled by streamingRouter further down the Cascade.
final recordingsRouter = Router()
..get('/', recordingsApi.handleGetAll)
..post('/', recordingsApi.handlePost)
..delete('/<id>', recordingsApi.handleDelete)
..post('/stop/<id>', recordingsApi.handleStop)
..get('/logs/<id>', recordingsApi.getLogHandler);
final epgRouter = Router()..get('/<channelId>', epgApi.handleGetEpg);
final seasonPassesRouter = Router()
..get('/', seasonPassesApi.handleGetAll)
..post('/', seasonPassesApi.handlePost)
..delete('/<id>', seasonPassesApi.handleDelete);
// Setup router
final apiRouter = Router()
// Auth endpoints (with per-IP brute-force protection on login)
..mount(
'/api/auth',
const Pipeline()
.addMiddleware(loginRateLimitMiddleware())
.addHandler(authHandler.router.call),
)
// Xtream API gateway: injects credentials server-side so the
// frontend never sees them
..mount(
'/api/xtream-api',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(xtreamApiHandler.handle),
)
// Playlists endpoints
..mount(
'/api/playlists',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(playlistsHandler.router.call),
)
// Users endpoints
..mount(
'/api/users',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(usersHandler.router.call),
)
// Settings endpoints
..mount(
'/api/settings',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(settingsHandler.router.call),
)
// TV Recordings (auth required)
..mount(
'/api/recordings',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(recordingsRouter.call),
)
// EPG - guide TV (auth required)
..mount(
'/api/epg',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(epgRouter.call),
)
// Season Passes - enregistrements répétés (auth required)
..mount(
'/api/season-passes',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(seasonPassesRouter.call),
);
// NOTE: /api/xtream is handled by proxyHandler in the Cascade below
// Do NOT mount here as it would intercept and block the actual proxy
// Initialize Cleanup Service
final cleanupService = CleanupService();
cleanupService.addTarget(Directory.systemTemp);
cleanupService.addTarget(Directory('/app/data/logs'));
cleanupService.addTarget(Directory('/app/data/tmp'));
cleanupService.start();
// Admin Routes (protected)
apiRouter.mount(
'/api/admin',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler((Request request) {
final router = Router();
// POST /api/admin/purge
router.post('/purge', (Request req) async {
final user = req.context['user'] as User?;
if (user == null || !user.isAdmin) {
return Response.forbidden(
jsonEncode({'error': 'Admin access required'}),
);
}
final result = await cleanupService.runCleanup();
return Response.ok(
jsonEncode(result),
headers: {'content-type': 'application/json'},
);
});
return router(request);
}),
);
// Create static handler
final baseStaticHandler = createStaticHandler(
webPath,
defaultDocument: 'index.html',
listDirectories: false,
);
// Wrap static handler to enforce cache policies
FutureOr<Response> staticHandler(Request request) async {
final response = await baseStaticHandler(request);
// Disable cache for entry points to ensure updates are seen immediately.
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
// versions: keep them cacheable or every player open re-downloads them.
final path = request.url.path;
final isVendored = path.startsWith('vendor/');
if (!isVendored &&
(path.isEmpty ||
path == 'index.html' ||
path.endsWith('.js') ||
path.endsWith('.json'))) {
return response.change(
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
'Expires': '0',
},
);
}
// Allow aggressive caching for hashed assets
return response.change(
headers: {
'Cache-Control': 'public, max-age=86400',
},
);
}
// Create Streaming Router (Mount handlers on correct paths)
final streamingRouter = Router()
..mount(
'/api/live',
createLiveStreamHandler(
getPlaylist,
isGpuEnabled: db.isNvidiaGpuEnabled,
),
)
..mount(
'/api/vod',
createVodStreamHandler(
getPlaylist,
isGpuEnabled: db.isNvidiaGpuEnabled,
),
)
..mount(
'/api/recordings/stream',
createRecordingStreamHandler(
db,
isGpuEnabled: db.isNvidiaGpuEnabled,
),
);
// Proxy Handler (auth is now handled INSIDE the handler, after path check)
// This allows non-/api/xtream requests to fall through to static handler
final proxyPipeline = proxyHandler.handler;
// Streaming routes accept the HttpOnly session cookie (hls.js cannot send
// Authorization headers); local FFmpeg loopback fetches bypass auth.
final protectedStreaming = const Pipeline()
.addMiddleware(streamAuthMiddleware(db))
.addHandler(streamingRouter.call);
// Main handler
final handler = Cascade()
.add(apiRouter.call) /* Standard API endpoints */
.add(proxyPipeline) /* Xtream Proxy (auth inside handler) */
.add(protectedStreaming) /* Streaming endpoints */
.add(staticHandler)
.handler;
// Add middleware
final pipeline = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(securityHeadersMiddleware())
.addMiddleware(honeypotMiddleware())
.addMiddleware(rateLimitMiddleware())
.addMiddleware(_corsMiddleware())
.addHandler(handler);
// Start server
final server = await shelf_io.serve(
pipeline,
InternetAddress.anyIPv4,
port,
);
print('Server started on port ${server.port}');
print('Serving static files from: $webPath');
print('REST API available at: /api/auth/* and /api/playlists/*');
print('Xtream proxy available at: /api/xtream/* (SSRF Protected)');
// Clean expired sessions periodically (every hour)
Timer.periodic(const Duration(hours: 1), (_) {
db.cleanExpiredSessions();
print('Cleaned expired sessions');
});
}
/// CORS middleware.
///
/// The app is served same-origin by this server, so CORS headers are only
/// needed for development (Flutter dev server on another port) or an
/// explicitly configured external origin via the ALLOWED_ORIGIN env var.
/// The request Origin is echoed back only when it matches the allowlist —
/// never a wildcard.
Middleware _corsMiddleware() {
final extraOrigin = Platform.environment['ALLOWED_ORIGIN'];
bool isAllowed(String origin) {
if (extraOrigin != null && extraOrigin.isNotEmpty && origin == extraOrigin) {
return true;
}
// Local development origins (flutter run -d chrome, etc.)
final uri = Uri.tryParse(origin);
return uri != null && (uri.host == 'localhost' || uri.host == '127.0.0.1');
}
return (Handler handler) {
return (Request request) async {
final origin = request.headers['origin'];
final headers = <String, String>{
if (origin != null && isAllowed(origin)) ...{
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers':
'Origin, Content-Type, Accept, Authorization, Range',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
},
};
// Handle preflight requests
if (request.method == 'OPTIONS') {
return Response.ok('', headers: headers);
}
// Process request and add CORS headers to response
final response = await handler(request);
return headers.isEmpty ? response : response.change(headers: headers);
};
};
}
-109
View File
@@ -1,109 +0,0 @@
import 'dart:io';
import 'dart:async';
class CleanupService {
final List<Directory> _targetDirectories = [];
Timer? _timer;
final Duration _defaultMaxAge;
CleanupService({Duration defaultMaxAge = const Duration(hours: 24)})
: _defaultMaxAge = defaultMaxAge;
/// Add a directory to be monitored
void addTarget(Directory dir) {
if (!dir.existsSync()) {
try {
dir.createSync(recursive: true);
print('CleanupService: Created missing directory: ${dir.path}');
} catch (e) {
print('CleanupService: Should monitor ${dir.path} but it does not exist and could not be created: $e');
return;
}
}
_targetDirectories.add(dir);
print('CleanupService: Monitoring ${dir.path}');
}
/// Start the periodic cleanup task
void start({Duration interval = const Duration(hours: 6)}) {
_timer?.cancel();
print('CleanupService: Starting scheduler (Interval: ${interval.inHours}h)');
// Run immediately on start
runCleanup();
_timer = Timer.periodic(interval, (_) => runCleanup());
}
/// Stop the scheduler
void stop() {
_timer?.cancel();
print('CleanupService: Scheduler stopped');
}
/// Trigger a manual cleanup
Future<Map<String, dynamic>> runCleanup() async {
print('CleanupService: Running cleanup task...');
int deletedCount = 0;
int freedBytes = 0;
List<String> errors = [];
for (final dir in _targetDirectories) {
if (!dir.existsSync()) continue;
try {
final result = await _cleanDirectory(dir, _defaultMaxAge);
deletedCount += result.count;
freedBytes += result.bytes;
} catch (e) {
print('CleanupService: Error processing ${dir.path}: $e');
errors.add('Error in ${dir.path}: $e');
}
}
final summary = {
'status': 'complete',
'deleted_files': deletedCount,
'freed_bytes': freedBytes,
'timestamp': DateTime.now().toIso8601String(),
'errors': errors,
};
print('CleanupService: Cleanup complete. Deleted $deletedCount files, freed ${(freedBytes / 1024 / 1024).toStringAsFixed(2)} MB');
return summary;
}
Future<({int count, int bytes})> _cleanDirectory(Directory dir, Duration maxAge) async {
int count = 0;
int bytes = 0;
final now = DateTime.now();
final threshold = now.subtract(maxAge);
try {
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
final stat = await entity.stat();
// Check Modified AND Accessed time (if available) to be safe
// Ideally we use modified time for temp files
if (stat.modified.isBefore(threshold)) {
final fileSize = stat.size;
await entity.delete();
count++;
bytes += fileSize;
// print('CleanupService: Deleted ${entity.path} (Age: ${now.difference(stat.modified).inHours}h)');
}
} catch (e) {
// Ignore individual file errors (locked files etc)
// print('CleanupService: Failed to delete ${entity.path}: $e');
}
}
}
} catch (e) {
print('CleanupService: Failed to list ${dir.path}: $e');
rethrow;
}
return (count: count, bytes: bytes);
}
}
-242
View File
@@ -1,242 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// One running FFmpeg transcoding session (live, VOD or recording playback).
class FfmpegSession {
final String id;
final Process process;
final Directory dir;
final bool isLive;
final DateTime startedAt = DateTime.now();
DateTime lastAccess = DateTime.now();
bool exited = false;
int? exitCode;
/// Last stderr lines, kept for fast-fail diagnostics.
final List<String> recentStderr = [];
FfmpegSession({
required this.id,
required this.process,
required this.dir,
required this.isLive,
});
void touch() => lastAccess = DateTime.now();
}
/// Registry of FFmpeg transcoding processes.
///
/// Responsibilities:
/// - one process per session id (`live_{id}_{quality}`, `vod_{id}`, ...)
/// - reaper kills sessions idle beyond a TTL (no viewer fetching segments)
/// - startup wipe of the HLS temp dir (orphan dirs after a crash)
/// - SIGTERM/SIGINT hook so `docker stop` leaves no orphan ffmpeg
class FfmpegSessionManager {
final Directory baseDir;
final Map<String, FfmpegSession> _sessions = {};
Timer? _reaper;
static const liveIdleTimeout = Duration(minutes: 4);
static const vodIdleTimeout = Duration(minutes: 15);
/// Live playlist not rewritten for this long => FFmpeg is wedged.
static const liveStallTimeout = Duration(seconds: 45);
FfmpegSessionManager(this.baseDir);
/// Wipe orphan session dirs and start the reaper. Call once at startup.
Future<void> init() async {
if (baseDir.existsSync()) {
try {
baseDir.deleteSync(recursive: true);
} catch (e) {
print('[FFmpegManager] Could not wipe temp dir: $e');
}
}
baseDir.createSync(recursive: true);
_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);
});
}
FfmpegSession? get(String id) => _sessions[id];
/// Marks a session as recently used (call from playlist AND segment routes).
void touch(String id) => _sessions[id]?.touch();
bool contains(String id) => _sessions.containsKey(id);
/// Returns the existing healthy session or starts a new FFmpeg process.
///
/// [argsBuilder] receives the session working directory and returns the
/// FFmpeg argument list. The session directory is recreated for new
/// sessions.
Future<FfmpegSession> getOrStart({
required String id,
required bool isLive,
required String ffmpegPath,
required List<String> Function(Directory dir) argsBuilder,
}) async {
final existing = _sessions[id];
if (existing != null && !existing.exited) {
existing.touch();
return existing;
}
if (existing != null) {
// A VOD/recording transcode that finished cleanly is still fully
// playable from its segments — reuse it instead of re-transcoding.
if (!isLive && existing.exitCode == 0 && _playlistComplete(existing)) {
existing.touch();
return existing;
}
// Process died: clean up before restarting
killSession(id);
}
final dir = Directory('${baseDir.path}/$id');
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
final args = argsBuilder(dir);
final process = await Process.start(
ffmpegPath,
args,
workingDirectory: dir.path,
);
final session = FfmpegSession(
id: id,
process: process,
dir: dir,
isLive: isLive,
);
_sessions[id] = session;
process.stderr.transform(utf8.decoder).listen((data) {
session.recentStderr.add(data);
if (session.recentStderr.length > 20) session.recentStderr.removeAt(0);
print('[FFmpeg $id] $data');
});
process.exitCode.then((code) {
session.exited = true;
session.exitCode = code;
print('[FFmpegManager] Session $id exited with code $code');
});
return session;
}
bool _playlistComplete(FfmpegSession session) {
final playlistFile = File('${session.dir.path}/playlist.m3u8');
if (!playlistFile.existsSync()) return false;
try {
return playlistFile.readAsStringSync().contains('#EXT-X-ENDLIST');
} catch (_) {
return false;
}
}
/// Waits until the session's playlist references at least one segment.
/// Fails fast when the process dies before producing output, returning
/// the recent stderr for diagnostics.
Future<({bool ready, String? error})> waitForPlaylist(
FfmpegSession session, {
Duration timeout = const Duration(seconds: 30),
}) async {
final playlistFile = File('${session.dir.path}/playlist.m3u8');
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (playlistFile.existsSync() &&
playlistFile.readAsStringSync().contains('.ts')) {
return (ready: true, error: null);
}
if (session.exited) {
return (
ready: false,
error: 'FFmpeg exited (${session.exitCode}): '
'${session.recentStderr.join().trim()}'
);
}
await Future.delayed(const Duration(milliseconds: 500));
}
return (ready: false, error: 'Timeout waiting for transcoder');
}
void killSession(String id) {
final session = _sessions.remove(id);
if (session == null) return;
try {
session.process.kill(ProcessSignal.sigterm);
} catch (_) {}
try {
if (session.dir.existsSync()) session.dir.deleteSync(recursive: true);
} catch (e) {
print('[FFmpegManager] Could not delete dir for $id: $e');
}
}
void killAll() {
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
for (final id in _sessions.keys.toList()) {
killSession(id);
}
_reaper?.cancel();
}
void _reap() {
final now = DateTime.now();
for (final session in _sessions.values.toList()) {
final timeout = session.isLive ? liveIdleTimeout : vodIdleTimeout;
final idle = now.difference(session.lastAccess);
// Exited sessions keep their segments until the idle timeout: a
// finished VOD transcode is usually still being watched, and killing
// it here would delete the segments mid-playback.
if (idle > timeout) {
print(
'[FFmpegManager] Reaping idle session ${session.id} '
'(idle ${idle.inSeconds}s)',
);
killSession(session.id);
continue;
}
// Watchdog: a live FFmpeg that is running but has not updated its
// playlist recently is wedged on a stalled upstream. Kill it so the
// player's next playlist poll restarts the transcoder.
if (session.isLive && !session.exited && _isStalled(session, now)) {
print('[FFmpegManager] Restarting stalled live session ${session.id}');
killSession(session.id);
}
}
}
/// A live session is stalled when its playlist exists but has not been
/// rewritten for [liveStallTimeout] (FFmpeg rewrites it on every segment).
bool _isStalled(FfmpegSession session, DateTime now) {
final playlistFile = File('${session.dir.path}/playlist.m3u8');
if (!playlistFile.existsSync()) {
// Never produced output: give it until liveStallTimeout after start.
return now.difference(session.startedAt) > liveStallTimeout;
}
try {
return now.difference(playlistFile.lastModifiedSync()) >
liveStallTimeout;
} catch (_) {
return false;
}
}
}
-126
View File
@@ -1,126 +0,0 @@
/// Pure decision logic for the recording scheduler, extracted for testability.
enum RecordingAction {
/// Start the recording now.
start,
/// Capacity reached but the window is still open: retry on a later tick.
wait,
/// The end time passed before the recording could start.
fail,
/// Not yet due — nothing to do.
none,
}
/// Decides what to do with a scheduled recording on a scheduler tick.
RecordingAction decideRecordingAction({
required DateTime now,
required DateTime startTime,
required DateTime endTime,
required int activeCount,
required int maxConcurrent,
}) {
final nowUtc = now.toUtc();
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
if (activeCount >= maxConcurrent) return RecordingAction.wait;
return RecordingAction.start;
}
/// Ce que le planificateur doit faire d'un enregistrement resté au statut
/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond
/// (redémarrage du conteneur, crash de l'isolate…).
enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
finish,
/// Rien d'exploitable n'a été capturé.
fail,
}
/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick.
OrphanAction decideOrphanAction({
required DateTime now,
required DateTime endTime,
required bool hasFile,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining) return OrphanAction.resume;
return hasFile ? OrphanAction.finish : OrphanAction.fail;
}
/// Ce que le planificateur doit faire quand un processus FFmpeg se termine.
enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé.
complete,
/// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre.
retry,
/// Arrêt prématuré sans rien d'exploitable.
fail,
}
/// Nombre d'échecs FFmpeg consécutifs tolérés avant d'abandonner un
/// enregistrement dont la fenêtre est encore ouverte.
///
/// Le compteur est remis à zéro dès qu'un lancement a duré assez longtemps
/// pour être considéré sain : une coupure toutes les dix minutes ne doit pas
/// finir par épuiser le quota.
const int maxFfmpegAttempts = 30;
/// Décide de la suite à donner à la sortie d'un processus FFmpeg.
///
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
PostExitAction decidePostExitAction({
required DateTime now,
required DateTime endTime,
required int exitCode,
required int consecutiveFailures,
required bool hasFile,
int maxAttempts = maxFfmpegAttempts,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) {
return PostExitAction.retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
final cleanExit = exitCode == 0 || exitCode == 255;
return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail;
}
/// Durée de capture à demander à FFmpeg (`-t`).
///
/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur la
/// durée théorique du programme : un démarrage tardif (ou une relance après
/// coupure) ne doit pas décaler la fin de l'enregistrement.
Duration captureDuration({
required DateTime now,
required DateTime endTime,
Duration minimum = const Duration(seconds: 30),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
return remaining < minimum ? minimum : remaining;
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
Duration ffmpegRetryDelay(int consecutiveFailures) {
final steps = (consecutiveFailures - 1).clamp(0, 4);
final seconds = 3 * (1 << steps);
return Duration(seconds: seconds > 30 ? 30 : seconds);
}
-786
View File
@@ -1,786 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../database/database.dart';
import '../models/recording.dart';
import '../utils/log_redactor.dart';
import 'recording_decision.dart';
import 'package:path/path.dart' as p;
/// Dossier où sont écrits les enregistrements et leurs logs.
final String recordingsDirPath =
Platform.environment['RECORDINGS_DIR'] ?? '/app/recordings';
/// Binaire FFmpeg utilisé pour la capture et la fusion des parties.
Future<String> resolveFfmpegPath() async {
final fromEnv = Platform.environment['FFMPEG_PATH'];
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
if (Platform.isLinux && await File('/usr/local/bin/ffmpeg').exists()) {
return '/usr/local/bin/ffmpeg';
}
return 'ffmpeg'; // Depuis le PATH
}
/// Écrivain de log tolérant aux fermetures.
///
/// FFmpeg continue de livrer des morceaux de `stderr` après la résolution de
/// `exitCode` : écrire sur un `IOSink` déjà fermé lève une `StateError` depuis
/// un callback de stream, c'est-à-dire une erreur asynchrone non rattrapée qui
/// tue l'isolate — et donc le serveur entier, laissant l'enregistrement en
/// cours au statut « recording » (« Interruption inattendue du serveur »).
class _RecordingLog {
final IOSink _sink;
bool _closed = false;
_RecordingLog(this._sink) {
// Une erreur d'écriture disque ne doit pas non plus remonter en erreur
// asynchrone non rattrapée.
_sink.done.catchError((_) {});
}
/// Ouvre le log, ou renvoie `null` si le fichier n'est pas créable :
/// l'enregistrement reste prioritaire sur sa journalisation.
static _RecordingLog? open(String path, {bool append = false}) {
try {
return _RecordingLog(
File(path).openWrite(mode: append ? FileMode.append : FileMode.write),
);
} catch (e) {
print(
'[RecordingScheduler] AVERTISSEMENT: log indisponible ($path): $e',
);
return null;
}
}
void writeln([String line = '']) => _guard(() => _sink.writeln(line));
void add(List<int> data) => _guard(() => _sink.add(data));
void _guard(void Function() action) {
if (_closed) return;
try {
action();
} catch (_) {
// Sink cassé : on arrête d'écrire plutôt que de propager.
_closed = true;
}
}
Future<void> close() async {
if (_closed) return;
_closed = true;
try {
await _sink.close();
} catch (_) {}
}
}
class _ActiveRecording {
final Recording recording;
/// Fichier principal (celui exposé par l'API).
final String filePath;
final _RecordingLog? log;
/// Fichiers additionnels écrits après une relance, dans l'ordre.
final List<String> extraParts;
/// Index du lancement FFmpeg en cours (0 = fichier principal).
int attempt;
/// Échecs consécutifs de FFmpeg, remis à zéro dès qu'une capture tient
/// assez longtemps pour être considérée saine.
int consecutiveFailures = 0;
/// Heure du dernier lancement de FFmpeg.
DateTime launchedAt = DateTime.now();
Process? process;
/// Arrêt volontaire : la sortie de FFmpeg ne doit pas déclencher de relance.
bool stopping = false;
_ActiveRecording({
required this.recording,
required this.filePath,
required this.log,
this.attempt = 0,
List<String>? extraParts,
}) : extraParts = extraParts ?? <String>[];
}
class RecordingScheduler {
final AppDatabase _db;
Timer? _timer;
Timer? _seasonPassTimer;
bool _isRunning = false;
/// Enregistrements actuellement en cours, indexés par id.
final Map<String, _ActiveRecording> _active = {};
/// Nombre maximum d'enregistrements simultanés (env MAX_CONCURRENT_RECORDINGS).
final int maxConcurrent = int.tryParse(
Platform.environment['MAX_CONCURRENT_RECORDINGS'] ?? '',
) ??
2;
// Playlist config pour les appels EPG des season passes
// Rempli depuis server.dart après initialisation
String? playlistDns;
String? playlistUsername;
String? playlistPassword;
RecordingScheduler(this._db);
void start() {
print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
'(max simultanés: $maxConcurrent)',
);
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
_timer = Timer.periodic(
const Duration(seconds: 10),
(_) => _checkAndRunRecordings(),
);
// Season Passes : vérifier toutes les 4 heures
_seasonPassTimer =
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
// Lancer une première vérification immédiatement
_checkAndRunRecordings();
// Vérification initiale des season passes après 30s (laisser le serveur démarrer)
Timer(const Duration(seconds: 30), _checkSeasonPasses);
}
void stop() {
_timer?.cancel();
_seasonPassTimer?.cancel();
for (final id in _active.keys.toList()) {
_stopActiveRecording(id, reason: 'Arrêt du service planificateur');
}
print('[RecordingScheduler] Arrêté');
}
Future<void> _checkAndRunRecordings() async {
if (_isRunning) return;
_isRunning = true;
try {
// Toujours comparer en UTC pour éviter les problèmes de fuseau horaire
final now = DateTime.now().toUtc();
// Arrêter les enregistrements actifs dont l'heure de fin est passée
for (final id in _active.keys.toList()) {
final active = _active[id]!;
if (now.isAfter(active.recording.endTime.toUtc())) {
print(
'[RecordingScheduler] Fin de l\'enregistrement : ${active.recording.title}',
);
_stopActiveRecording(id);
}
}
// 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();
// Rechercher les enregistrements planifiés
for (final recording in recordings) {
// Enregistrement marqué "recording" sans processus associé : le serveur
// a redémarré (ou l'enregistrement n'a jamais démarré).
if (recording.status == 'recording' &&
!_active.containsKey(recording.id)) {
await _recoverOrphan(recording, now);
continue;
}
if (recording.status == 'scheduled') {
final action = decideRecordingAction(
now: now,
startTime: recording.startTime,
endTime: recording.endTime,
activeCount: _active.length,
maxConcurrent: maxConcurrent,
);
switch (action) {
case RecordingAction.start:
print(
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***',
);
await _startRecording(recording);
case RecordingAction.wait:
// Capacité atteinte mais la fenêtre est encore ouverte :
// on garde le statut "scheduled" et on réessaiera au prochain tick.
print(
'[RecordingScheduler] Capacité max atteinte (${_active.length}/$maxConcurrent), '
'"${recording.title}" en attente.',
);
case RecordingAction.fail:
print(
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).',
);
_db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Heure de fin dépassée avant le démarrage',
);
case RecordingAction.none:
break;
}
}
}
} catch (e, st) {
print('[RecordingScheduler] ERREUR CRITIQUE: $e\n$st');
} finally {
_isRunning = false;
}
}
/// Reprend (ou clôture) un enregistrement laissé au statut "recording" par
/// une interruption du serveur.
Future<void> _recoverOrphan(Recording recording, DateTime now) async {
// Le statut a pu changer depuis la lecture (clôture asynchrone en cours) :
// ne jamais requalifier un enregistrement sur une donnée périmée.
final current = _db.getRecordingById(recording.id);
if (current == null || current.status != 'recording') return;
final action = decideOrphanAction(
now: now,
endTime: current.endTime,
hasFile: _fileHasData(current.filePath),
);
switch (action) {
case OrphanAction.resume:
if (_active.length >= maxConcurrent) {
// Pas de slot libre pour l'instant : on retentera au prochain tick.
return;
}
print(
'[RecordingScheduler] Reprise après interruption : ${current.title}',
);
await _startRecording(current, resume: true);
case OrphanAction.finish:
print(
'[RecordingScheduler] Enregistrement interrompu conservé (partiel): ${current.title}',
);
_db.updateRecordingStatus(
current.id,
'completed',
errorReason:
'Interruption du serveur pendant la capture : fichier partiel',
);
case OrphanAction.fail:
print(
'[RecordingScheduler] Enregistrement orphelin sans fichier: ${current.id}',
);
_db.updateRecordingStatus(
current.id,
'failed',
errorReason: 'Interruption inattendue du serveur',
);
}
}
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) {
try {
final channelId = pass['channel_id'] as String;
final streamUrl = pass['stream_url'] as String;
final showTitle = pass['show_title'] as String;
final userId = pass['user_id'] as String;
// Récupérer l'EPG de la chaîne (48 prochaines heures)
final url = '$dns/player_api.php?username=$username&password=$password'
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
if (response.statusCode != 200) continue;
final raw = json.decode(response.body);
final listings =
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
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;
// Parser les heures de début/fin
final startStr = item['start'] as String? ?? '';
final endStr =
item['stop'] as String? ?? item['end'] as String? ?? '';
if (startStr.isEmpty || endStr.isEmpty) continue;
DateTime startTime, endTime;
try {
startTime = DateTime.parse(startStr).toUtc();
endTime = DateTime.parse(endStr).toUtc();
} catch (_) {
continue;
}
// 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.');
continue;
}
// Créer l'enregistrement automatiquement
_db.createRecording(
userId: userId,
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
);
print(
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
);
}
} catch (e) {
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
}
}
}
Future<void> _startRecording(
Recording recording, {
bool resume = false,
}) async {
// Un (re)démarrage rend obsolète un éventuel motif d'échec précédent.
_db.updateRecordingStatus(recording.id, 'recording', clearError: true);
// Un seul bloc try/catch englobant TOUT pour éviter les Unhandled exceptions
// qui tueraient le serveur entier
try {
// Préparation du dossier d'enregistrement
final recordingsDir = Directory(recordingsDirPath);
if (!await recordingsDir.exists()) {
await recordingsDir.create(recursive: true);
}
// Nettoyer l'espace disque si nécessaire
await _checkDiskSpaceAndRotate(recordingsDir);
final filePath = p.join(recordingsDir.path, _fileNameFor(recording));
final logPath = p.setExtension(filePath, '.log');
final active = _ActiveRecording(
recording: recording,
filePath: filePath,
// Une reprise complète le log existant au lieu de l'écraser.
log: _RecordingLog.open(logPath, append: resume),
);
if (resume) {
// Le fichier principal (et d'éventuelles parties) survivent au
// redémarrage : on repart sur une nouvelle partie pour ne rien écraser.
active.extraParts.addAll(_existingParts(filePath));
active.attempt = _fileHasData(filePath) || active.extraParts.isNotEmpty
? active.extraParts.length + 1
: 0;
active.log?.writeln(
'\n[${DateTime.now()}] Reprise après interruption du serveur',
);
} else {
active.log?.writeln('[${DateTime.now()}] Démarrage: ${recording.title}');
active.log?.writeln('Destination: $filePath');
}
_active[recording.id] = active;
// Enregistrer le chemin du fichier dans la BDD
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
await _launchFfmpeg(active);
} catch (e, st) {
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
_db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Erreur au lancement: $e',
);
final active = _active.remove(recording.id);
unawaited(active?.log?.close() ?? Future<void>.value());
}
}
/// Lance (ou relance) le processus FFmpeg d'un enregistrement actif.
Future<void> _launchFfmpeg(_ActiveRecording active) async {
final recording = active.recording;
final output = active.attempt == 0
? active.filePath
: _partPath(active.filePath, active.attempt);
if (active.attempt > 0 && !active.extraParts.contains(output)) {
active.extraParts.add(output);
}
// Résoudre l'URL relative en URL absolue pour FFmpeg
// Le serveur Xtremflow tourne sur le port 8089 en interne Docker
String streamUrl = recording.streamUrl;
if (streamUrl.startsWith('/')) {
streamUrl = 'http://localhost:8089$streamUrl';
}
final duration = captureDuration(
now: DateTime.now().toUtc(),
endTime: recording.endTime.toUtc(),
);
final args = [
'-hide_banner',
// Les logs sont relus entièrement par l'API : sans -nostats, une heure de
// capture produit des mégaoctets de lignes de progression.
'-nostats',
'-user_agent', 'VLC/3.0.18 LibVLC/3.0.18',
// Une coupure amont (bascule de source, recyclage nginx du panneau) ne
// doit pas terminer la capture : FFmpeg rouvre le flux lui-même.
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '30',
'-rw_timeout', '30000000',
'-y',
'-i', streamUrl,
'-c', 'copy',
'-t', '${duration.inSeconds}',
output,
];
final ffmpegPath = await resolveFfmpegPath();
// Le contenu du log est exposé via l'API : ne jamais y écrire de credentials.
final redactedArgs = args.map(LogRedactor.redactUrl).join(' ');
active.log?.writeln('URL: ${LogRedactor.redactUrl(streamUrl)}');
active.log?.writeln('Commande: $ffmpegPath $redactedArgs\n');
print('[RecordingScheduler] Exécution: $ffmpegPath $redactedArgs');
final process = await Process.start(ffmpegPath, args);
active.process = process;
active.launchedAt = DateTime.now();
// Rediriger stdout/stderr dans le log ; on attend la fin des deux flux
// avant de fermer le sink, sinon les derniers morceaux arrivent sur un
// fichier déjà fermé.
final drained = Future.wait([
_pipeToLog(process.stdout, active.log),
_pipeToLog(process.stderr, active.log),
]);
unawaited(
process.exitCode.then((exitCode) async {
await drained;
await _onFfmpegExit(active, exitCode);
}).catchError((Object e, StackTrace st) {
print('[RecordingScheduler] ERREUR à la sortie de FFmpeg: $e\n$st');
}),
);
}
Future<void> _pipeToLog(Stream<List<int>> stream, _RecordingLog? log) {
return stream.forEach((chunk) => log?.add(chunk)).catchError((_) {});
}
/// Décide de la suite quand un processus FFmpeg se termine.
Future<void> _onFfmpegExit(_ActiveRecording active, int exitCode) async {
final recording = active.recording;
// Arrêt volontaire, ou enregistrement déjà repris ailleurs : la clôture est
// gérée par celui qui a demandé l'arrêt.
if (active.stopping || !identical(_active[recording.id], active)) return;
// Une capture qui a tenu longtemps repart d'un quota de relances neuf.
final lasted = DateTime.now().difference(active.launchedAt);
if (lasted > const Duration(minutes: 2) && _activeHasData(active)) {
active.consecutiveFailures = 0;
}
final action = decidePostExitAction(
now: DateTime.now().toUtc(),
endTime: recording.endTime.toUtc(),
exitCode: exitCode,
consecutiveFailures: active.consecutiveFailures,
hasFile: _activeHasData(active),
);
switch (action) {
case PostExitAction.retry:
active.attempt++;
active.consecutiveFailures++;
final delay = ffmpegRetryDelay(active.consecutiveFailures);
final message =
'FFmpeg s\'est arrêté (code $exitCode) après ${lasted.inSeconds}s, '
'avant la fin prévue — relance dans ${delay.inSeconds}s '
'(tentative ${active.consecutiveFailures}/${maxFfmpegAttempts - 1})';
print('[RecordingScheduler] ${recording.title}: $message');
active.log?.writeln('\n[${DateTime.now()}] $message');
// Laisser la source respirer : sur les comptes limités en connexions
// simultanées, rouvrir immédiatement se fait refuser.
await Future<void>.delayed(delay);
if (active.stopping || !identical(_active[recording.id], active)) {
return;
}
try {
await _launchFfmpeg(active);
} catch (e) {
print('[RecordingScheduler] Relance impossible: $e');
_active.remove(recording.id);
await _finalize(
active,
status: 'failed',
errorReason: 'Relance de FFmpeg impossible: $e',
);
}
case PostExitAction.complete:
print(
'[RecordingScheduler] Enregistrement terminé: ${recording.title}',
);
_active.remove(recording.id);
await _finalize(active, status: 'completed');
case PostExitAction.fail:
print(
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}',
);
_active.remove(recording.id);
await _finalize(
active,
status: 'failed',
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.',
);
}
}
/// Clôture un enregistrement : fusion des parties, fermeture du log, statut.
Future<void> _finalize(
_ActiveRecording active, {
required String status,
String? errorReason,
}) async {
try {
await _mergeParts(active);
} catch (e) {
print('[RecordingScheduler] Fusion des parties impossible: $e');
}
active.log?.writeln(
'\n[${DateTime.now()}] Enregistrement clôturé avec le statut "$status"',
);
await active.log?.close();
_db.updateRecordingStatus(
active.recording.id,
status,
filePath: active.filePath,
errorReason: errorReason,
);
}
/// Recolle les parties issues des relances dans le fichier principal.
///
/// Les parties proviennent de la même source avec `-c copy` : le demuxer
/// `concat` de FFmpeg les rassemble sans réencodage. En cas d'échec, le
/// fichier principal et les parties sont conservés tels quels.
Future<void> _mergeParts(_ActiveRecording active) async {
final parts = active.extraParts.where(_fileHasData).toList();
if (parts.isEmpty) return;
final segments = [
if (_fileHasData(active.filePath)) active.filePath,
...parts,
];
if (segments.length < 2) {
// Le fichier principal est vide : la première partie le remplace.
await File(segments.first).rename(active.filePath);
return;
}
final listFile = File('${active.filePath}.parts.txt');
await listFile.writeAsString(
segments.map((s) => 'file \'${s.replaceAll("'", r"'\''")}\'').join('\n'),
);
final mergedPath = '${active.filePath}.merged.mkv';
final ffmpegPath = await resolveFfmpegPath();
active.log?.writeln(
'\n[${DateTime.now()}] Fusion de ${segments.length} parties…',
);
final result = await Process.run(ffmpegPath, [
'-hide_banner',
'-nostats',
'-y',
'-f', 'concat',
'-safe', '0',
'-i', listFile.path,
'-c', 'copy',
mergedPath,
]);
await _deleteQuietly(listFile.path);
if (result.exitCode != 0 || !_fileHasData(mergedPath)) {
active.log?.writeln(
'Fusion échouée (code ${result.exitCode}) : les parties sont '
'conservées séparément.',
);
await _deleteQuietly(mergedPath);
return;
}
await File(mergedPath).rename(active.filePath);
for (final part in parts) {
await _deleteQuietly(part);
}
active.log?.writeln('Fusion terminée.');
}
/// Arrêter un enregistrement en cours (appelé depuis l'API)
Future<bool> stopRecording(String id) async {
if (_active.containsKey(id)) {
print(
'[RecordingScheduler] Arrêt demandé pour: ${_active[id]!.recording.title}',
);
_stopActiveRecording(id);
return true;
}
return false; // Pas d'enregistrement actif avec cet ID
}
void _stopActiveRecording(String id, {String? reason}) {
final active = _active.remove(id);
if (active == null) return;
active.stopping = true;
if (reason != null) {
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
}
active.log?.writeln(
'\n[${DateTime.now()}] Arrêt: ${reason ?? 'fin de la fenêtre programmée'}',
);
active.process?.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed');
unawaited(_finalizeStopped(active));
}
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
Future<void> _finalizeStopped(_ActiveRecording active) async {
try {
final process = active.process;
if (process != null) {
await process.exitCode.timeout(
const Duration(seconds: 15),
onTimeout: () {
process.kill(ProcessSignal.sigkill);
return -1;
},
);
}
await _finalize(active, status: 'completed');
} catch (e, st) {
print('[RecordingScheduler] ERREUR à la clôture: $e\n$st');
await active.log?.close();
}
}
String _fileNameFor(Recording recording) {
// Génération d'un nom de fichier unique et sûr
final safeTitle =
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
final dateStr = recording.startTime
.toUtc()
.toIso8601String()
.replaceAll(':', '')
.split('.')[0];
return '${safeTitle}_$dateStr.mkv';
}
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
String _partPath(String filePath, int attempt) =>
'${p.withoutExtension(filePath)}.part${attempt + 1}.mkv';
/// Parties déjà écrites sur disque pour cet enregistrement, dans l'ordre.
List<String> _existingParts(String filePath) {
final parts = <String>[];
for (var attempt = 1;; attempt++) {
final path = _partPath(filePath, attempt);
if (!_fileHasData(path)) break;
parts.add(path);
}
return parts;
}
bool _activeHasData(_ActiveRecording active) =>
_fileHasData(active.filePath) || active.extraParts.any(_fileHasData);
bool _fileHasData(String? path) {
if (path == null) return false;
try {
final file = File(path);
return file.existsSync() && file.lengthSync() > 0;
} catch (_) {
return false;
}
}
Future<void> _deleteQuietly(String path) async {
try {
final file = File(path);
if (await file.exists()) await file.delete();
} 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
try {
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
final files = dir.listSync().whereType<File>().toList();
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
// Supprimer les plus anciens pour revenir sous la limite
final filesToDelete = files.take(files.length - maxFiles);
for (var file in filesToDelete) {
file.deleteSync();
}
}
} catch (e) {
print(
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
);
}
}
}
-238
View File
@@ -1,238 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
class SimpleRecorder {
final AppDatabase db;
final String recordingsDir;
// Active recordings (streamId -> process)
final Map<String, _ActiveRecording> _active = {};
SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'});
/// Initialize recordings directory
Future<void> init() async {
final dir = Directory(recordingsDir);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
print('🎬 Recorder ready -> $recordingsDir');
}
/// Start recording immediately
Future<String> startRecording({
required String channelId,
required String streamUrl,
required String title,
required Duration duration,
}) async {
if (_active.containsKey(channelId)) {
throw Exception('Already recording: $title');
}
final recordingId = const Uuid().v4();
final now = DateTime.now();
final endTime = now.add(duration);
// Create recording in DB
db.createRecording(
userId: 'system',
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: now,
endTime: endTime,
);
// Generate safe filename
final filename = _safeName(title, recordingId);
final filepath = '$recordingsDir/$filename';
// Start FFmpeg process
try {
final ffmpeg = await Process.start('ffmpeg', [
'-y',
'-fflags', '+genpts+igndts', // FORCE generation of missing timestamps for MKV/MP4 compatibility
'-i',
streamUrl,
'-c',
'copy',
'-t',
'${duration.inSeconds}',
filepath,
]);
_active[channelId] = _ActiveRecording(
id: recordingId,
process: ffmpeg,
filepath: filepath,
endTime: endTime,
);
// Update DB
db.updateRecordingStatus(recordingId, 'recording', filePath: filepath);
// Auto-stop when time is up or process ends
ffmpeg.exitCode.then((code) {
if (code == 0 || code == 255) { // 255 is usually returned when gracefully stopped via q/SIGTERM
db.updateRecordingStatus(recordingId, 'completed');
} else {
db.updateRecordingStatus(
recordingId,
'failed',
errorReason: 'FFmpeg crashed with exit code $code',
);
}
_active.remove(channelId);
print('✅ Recording finished: $title (Code: $code)');
}).catchError((e) {
db.updateRecordingStatus(
recordingId,
'failed',
errorReason: 'FFmpeg error',
);
_active.remove(channelId);
});
print('🔴 Recording: $title ($duration)');
return recordingId;
} catch (e) {
db.updateRecordingStatus(recordingId, 'failed', errorReason: '$e');
rethrow;
}
}
/// Schedule recording for later
Future<String> scheduleRecording({
required String channelId,
required String streamUrl,
required String title,
required DateTime startTime,
required DateTime endTime,
}) async {
final recordingId = const Uuid().v4();
// Create in DB with 'scheduled' status
db.createRecording(
userId: 'system',
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
);
// Schedule auto-start
final delay = startTime.difference(DateTime.now());
if (delay.isNegative) {
throw Exception('Start time is in the past');
}
Timer(delay, () async {
try {
await startRecording(
channelId: channelId,
streamUrl: streamUrl,
title: title,
duration: endTime.difference(startTime),
);
} catch (e) {
print('❌ Auto-start failed: $e');
}
});
print('⏰ Scheduled: $title at ${startTime.toLocal()}');
return recordingId;
}
/// Stop recording immediately
Future<void> stopRecording(String channelId) async {
final active = _active[channelId];
if (active == null) return;
active.process.kill();
db.updateRecordingStatus(active.id, 'completed');
_active.remove(channelId);
print('⏹️ Stopped: $channelId');
}
/// Check scheduled recordings (call every minute)
Future<void> checkScheduled() async {
final now = DateTime.now();
for (final rec in db.getAllRecordings()) {
if (rec.status != 'scheduled') continue;
if (now.isBefore(rec.startTime)) continue;
try {
await startRecording(
channelId: rec.channelId,
streamUrl: rec.streamUrl,
title: rec.title,
duration: rec.endTime.difference(rec.startTime),
);
} catch (e) {
print('❌ Auto-start error: $e');
db.updateRecordingStatus(rec.id, 'failed', errorReason: '$e');
}
}
}
/// Get active recordings
List<Map<String, dynamic>> getActive() {
return _active.entries
.map(
(e) => {
'id': e.value.id,
'channel': e.key,
'filepath': e.value.filepath,
'endsAt': e.value.endTime.toIso8601String(),
},
)
.toList();
}
/// Cleanup old recordings (keep last 20)
Future<void> cleanupOld({int keepCount = 20}) async {
final dir = Directory(recordingsDir);
final files = dir.listSync().whereType<File>().toList()
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
if (files.length > keepCount) {
for (final file in files.skip(keepCount)) {
await file.delete();
print('🗑️ Deleted: ${file.path}');
}
}
}
String _safeName(String title, String id) {
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final timestamp =
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
return '${safe}_$timestamp.mkv'; // The user explicitly mandated MKV or MP4.
}
void dispose() {
for (final rec in _active.values) {
rec.process.kill();
}
}
}
/// Internal: Active recording tracking
class _ActiveRecording {
final String id;
final Process process;
final String filepath;
final DateTime endTime;
_ActiveRecording({
required this.id,
required this.process,
required this.filepath,
required this.endTime,
});
}
-305
View File
@@ -1,305 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:xml/xml_events.dart';
/// Guide TV construit à partir de dumps XMLTV publics.
///
/// Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou
/// n'en servent aucun : `get_simple_data_table` répond alors correctement mais
/// avec des programmes périmés. Ce service télécharge des dumps XMLTV
/// indépendants, les indexe par chaîne, et permet de compléter le guide.
///
/// Le parsing est fait en flux (`XmlEventReader`) : un dump national pèse
/// couramment 50 Mo décompressés, en charger l'arbre DOM complet coûterait
/// plusieurs centaines de mégaoctets dans le conteneur.
class XmltvEpgService {
XmltvEpgService({
required this.sourceUrls,
this.refreshInterval = const Duration(hours: 6),
this.retention = const Duration(hours: 6),
http.Client? client,
}) : _client = client ?? http.Client();
/// Dumps XMLTV à agréger, dans l'ordre de priorité décroissante.
final List<String> sourceUrls;
/// Fréquence de rafraîchissement de l'index.
final Duration refreshInterval;
/// Les programmes terminés depuis plus longtemps que cette durée sont
/// écartés à l'indexation : personne ne consulte le guide d'hier, et les
/// garder double la taille de l'index.
final Duration retention;
final http.Client _client;
/// clé de chaîne normalisée → programmes triés par heure de début.
Map<String, List<XmltvProgramme>> _index = {};
DateTime? _indexedAt;
Future<void>? _refreshInFlight;
bool get hasData => _index.isNotEmpty;
DateTime? get indexedAt => _indexedAt;
int get channelCount => _index.length;
/// Programmes d'une chaîne, ou liste vide si inconnue.
///
/// [channelId] est l'`epg_channel_id` renvoyé par Xtream ; [displayName] est
/// le nom de la chaîne, utilisé en second recours car les dumps publics
/// construisent souvent leur identifiant à partir du nom affiché.
Future<List<XmltvProgramme>> programmesFor(
String? channelId, {
String? displayName,
}) async {
await ensureFresh();
for (final candidate in [channelId, displayName]) {
final key = normalizeKey(candidate);
if (key.isEmpty) continue;
final hit = _index[key];
if (hit != null && hit.isNotEmpty) return hit;
}
return const [];
}
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
/// partagent le même téléchargement.
Future<void> ensureFresh() {
final age = _indexedAt == null
? null
: DateTime.now().difference(_indexedAt!);
if (age != null && age < refreshInterval) return Future.value();
return _refreshInFlight ??= _refresh().whenComplete(() {
_refreshInFlight = null;
});
}
Future<void> _refresh() async {
if (sourceUrls.isEmpty) return;
final merged = <String, List<XmltvProgramme>>{};
var ok = 0;
for (final url in sourceUrls) {
try {
final body = await _download(url);
final parsed = _parse(body);
// Première source servie gagne : les suivantes ne comblent que les
// chaînes encore absentes.
for (final entry in parsed.entries) {
merged.putIfAbsent(entry.key, () => entry.value);
}
ok++;
print(
'[XmltvEpg] $url : ${parsed.length} chaînes indexées',
);
} catch (e) {
print('[XmltvEpg] $url : échec ($e)');
}
}
if (ok == 0) {
// Garder l'index précédent plutôt que de servir un guide vide.
print('[XmltvEpg] aucune source disponible, index précédent conservé');
return;
}
_index = merged;
_indexedAt = DateTime.now();
print('[XmltvEpg] index prêt : ${merged.length} chaînes');
}
Future<String> _download(String url) async {
final response = await _client
.get(Uri.parse(url))
.timeout(const Duration(minutes: 5));
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}');
}
List<int> bytes = response.bodyBytes;
// Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on
// regarde le nombre magique plutôt que de se fier aux en-têtes.
if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) {
bytes = gzip.decode(bytes);
}
return utf8.decode(bytes, allowMalformed: true);
}
/// Exposé pour les tests.
Map<String, List<XmltvProgramme>> parseForTest(String xml) => _parse(xml);
Map<String, List<XmltvProgramme>> _parse(String xml) {
final cutoff = DateTime.now().toUtc().subtract(retention);
final byChannel = <String, List<XmltvProgramme>>{};
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
// <display-name> différent de l'identifiant. On indexe les deux pour
// maximiser les correspondances.
final aliases = <String, String>{};
String? channelId;
String? programmeChannel;
DateTime? start;
DateTime? stop;
String? currentTag;
final title = StringBuffer();
final desc = StringBuffer();
final displayName = StringBuffer();
for (final event in parseEvents(xml)) {
if (event is XmlStartElementEvent) {
switch (event.name) {
case 'channel':
channelId = _attr(event, 'id');
displayName.clear();
case 'programme':
programmeChannel = _attr(event, 'channel');
start = parseXmltvDate(_attr(event, 'start'));
stop = parseXmltvDate(_attr(event, 'stop'));
title.clear();
desc.clear();
case 'title':
case 'desc':
case 'display-name':
currentTag = event.name;
}
if (event.isSelfClosing) currentTag = null;
} else if (event is XmlTextEvent || event is XmlCDATAEvent) {
final text = event is XmlTextEvent
? event.value
: (event as XmlCDATAEvent).value;
switch (currentTag) {
case 'title':
title.write(text);
case 'desc':
desc.write(text);
case 'display-name':
if (displayName.isEmpty) displayName.write(text);
}
} else if (event is XmlEndElementEvent) {
switch (event.name) {
case 'channel':
final id = normalizeKey(channelId);
final name = normalizeKey(displayName.toString());
if (id.isNotEmpty && name.isNotEmpty && id != name) {
aliases[name] = id;
}
channelId = null;
case 'programme':
if (programmeChannel != null &&
start != null &&
stop != null &&
stop.isAfter(cutoff)) {
final key = normalizeKey(programmeChannel);
if (key.isNotEmpty) {
byChannel.putIfAbsent(key, () => []).add(
XmltvProgramme(
title: title.toString().trim(),
description: desc.toString().trim(),
start: start,
stop: stop,
),
);
}
}
programmeChannel = null;
start = null;
stop = null;
}
currentTag = null;
}
}
for (final list in byChannel.values) {
list.sort((a, b) => a.start.compareTo(b.start));
}
// Rendre les chaînes atteignables aussi par leur nom affiché.
aliases.forEach((name, id) {
final programmes = byChannel[id];
if (programmes != null) byChannel.putIfAbsent(name, () => programmes);
});
return byChannel;
}
static String _attr(XmlStartElementEvent event, String name) {
for (final attribute in event.attributes) {
if (attribute.name == name) return attribute.value;
}
return '';
}
/// Clé de correspondance : minuscules, sans ponctuation ni accents.
///
/// Les dumps publics écrivent `France.2.fr` là où le panneau annonce
/// `France2.fr` ; sans normalisation, la moitié des chaînes ne trouvent
/// jamais leur guide.
static String normalizeKey(String? raw) {
if (raw == null) return '';
final buffer = StringBuffer();
for (final rune in raw.toLowerCase().runes) {
final char = String.fromCharCode(rune);
final folded = _accents[char] ?? char;
if (RegExp(r'[a-z0-9]').hasMatch(folded)) buffer.write(folded);
}
return buffer.toString();
}
static const _accents = {
'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a',
'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i',
'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o',
'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u',
'ç': 'c', 'ñ': 'n',
};
/// « 20260811200000 +0200 » → instant UTC.
static DateTime? parseXmltvDate(String? raw) {
if (raw == null || raw.length < 14) return null;
final digits = raw.substring(0, 14);
final base = DateTime.tryParse(
'${digits.substring(0, 4)}-${digits.substring(4, 6)}-'
'${digits.substring(6, 8)}T${digits.substring(8, 10)}:'
'${digits.substring(10, 12)}:${digits.substring(12, 14)}Z',
);
if (base == null) return null;
final offset = raw.length >= 20 ? raw.substring(15, 20) : null;
if (offset == null || offset.length != 5) return base;
final sign = offset[0] == '-' ? -1 : 1;
final hours = int.tryParse(offset.substring(1, 3));
final minutes = int.tryParse(offset.substring(3, 5));
if (hours == null || minutes == null) return base;
return base.subtract(
Duration(hours: sign * hours, minutes: sign * minutes),
);
}
}
class XmltvProgramme {
const XmltvProgramme({
required this.title,
required this.description,
required this.start,
required this.stop,
});
final String title;
final String description;
final DateTime start;
final DateTime stop;
Map<String, dynamic> toJson(String channelId) => {
'title': title,
'description': description,
'start': start.toUtc().toIso8601String(),
'end': stop.toUtc().toIso8601String(),
'channel_id': channelId,
};
}
-74
View File
@@ -1,74 +0,0 @@
import 'package:test/test.dart';
import '../api/epg_api.dart';
/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) :
/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne
/// exprimée dans le fuseau du panneau — ici Europe/Amsterdam (UTC+2).
const _sampleListing = {
'epg_listings': [
{
'id': '336987829',
'epg_id': '104',
'title': 'Sm91cm5hbCBkZSAyMGg=', // "Journal de 20h"
'lang': '',
'start': '2026-08-11 20:00:00',
'end': '2026-08-11 20:35:00',
'description': 'TCdpbmZvIGR1IHNvaXI=', // "L'info du soir"
'channel_id': 'TF1.fr',
'start_timestamp': '1786471200', // 2026-08-11T18:00:00Z
'stop_timestamp': '1786473300', // 2026-08-11T18:35:00Z
},
],
};
void main() {
final api = EpgApi((_) async => null);
group('transformEpgData', () {
test('convertit les horodatages epoch en ISO-8601 UTC', () {
final result = api.transformEpgData(_sampleListing, '845452');
final programme = (result['programmes'] as List).single as Map;
// Sans cette conversion, le client relisait « 2026-08-11T20:00:00 »
// comme de l'heure locale et décalait tout le guide de 2 h.
expect(programme['start'], '2026-08-11T18:00:00.000Z');
expect(programme['end'], '2026-08-11T18:35:00.000Z');
});
test('décode les titres et descriptions base64', () {
final result = api.transformEpgData(_sampleListing, '845452');
final programme = (result['programmes'] as List).single as Map;
expect(programme['title'], 'Journal de 20h');
expect(programme['description'], "L'info du soir");
expect(programme['channel_id'], '845452');
});
test('retombe sur les champs texte quand les epoch manquent', () {
final result = api.transformEpgData({
'epg_listings': [
{
'title': 'Sm91cm5hbCBkZSAyMGg=',
'start': '2026-08-11 20:00:00',
'stop': '2026-08-11 20:35:00',
},
],
}, '845452');
final programme = (result['programmes'] as List).single as Map;
expect(programme['start'], '2026-08-11T20:00:00');
expect(programme['end'], '2026-08-11T20:35:00');
});
test('rend une liste vide sur un payload sans epg_listings', () {
// Ce que renvoie un panneau qui ne connaît pas l'action demandée :
// un 200 contenant le bloc d'authentification.
final result = api.transformEpgData({
'user_info': {'auth': 1},
'server_info': {'url': 'tit.example'},
}, '845452');
expect(result['programmes'], isEmpty);
});
});
}
-33
View File
@@ -1,33 +0,0 @@
import 'package:test/test.dart';
import '../utils/log_redactor.dart';
void main() {
group('LogRedactor.redactUrl', () {
test('masks credentials in query strings', () {
final out = LogRedactor.redactUrl(
'http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams',
);
expect(out, isNot(contains('john')));
expect(out, isNot(contains('hunter2')));
expect(out, contains('username=***'));
expect(out, contains('password=***'));
expect(out, contains('action=get_live_streams'));
});
test('masks credentials in live/movie/series path segments', () {
for (final kind in ['live', 'movie', 'series']) {
final out = LogRedactor.redactUrl(
'http://srv:8080/$kind/john/hunter2/12345.ts',
);
expect(out, isNot(contains('john')), reason: kind);
expect(out, isNot(contains('hunter2')), reason: kind);
expect(out, contains('/$kind/***/***/12345.ts'), reason: kind);
}
});
test('leaves URLs without credentials untouched', () {
const url = 'http://srv:8080/images/logo.png';
expect(LogRedactor.redactUrl(url), url);
});
});
}
-37
View File
@@ -1,37 +0,0 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
import '../utils/password_hasher.dart';
void main() {
group('PasswordHasher', () {
test('bcrypt roundtrip verifies the original password', () {
final hash = PasswordHasher.hash('s3cret-Pass!');
expect(hash, startsWith(r'$2'));
expect(PasswordHasher.verify('s3cret-Pass!', hash), isTrue);
expect(PasswordHasher.verify('wrong', hash), isFalse);
});
test('legacy unsalted SHA-256 hashes still verify', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.verify('admin', legacy), isTrue);
expect(PasswordHasher.verify('not-admin', legacy), isFalse);
});
test('isLegacy detects SHA-256 hex digests only', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.isLegacy(legacy), isTrue);
expect(PasswordHasher.isLegacy(PasswordHasher.hash('admin')), isFalse);
});
test('rehash produces a non-legacy hash', () {
final rehashed = PasswordHasher.hash('admin');
expect(PasswordHasher.isLegacy(rehashed), isFalse);
expect(PasswordHasher.verify('admin', rehashed), isTrue);
});
test('malformed hash never verifies', () {
expect(PasswordHasher.verify('x', 'garbage-hash'), isFalse);
});
});
}
-245
View File
@@ -1,245 +0,0 @@
import 'package:test/test.dart';
import '../services/recording_decision.dart';
void main() {
final now = DateTime.utc(2026, 6, 10, 12, 0);
group('decideRecordingAction', () {
test('starts when due and capacity available', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
test('waits (keeps scheduled) when at capacity but window still open', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 2,
maxConcurrent: 2,
),
RecordingAction.wait,
);
});
test('fails when the end time has passed', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(hours: 2)),
endTime: now.subtract(const Duration(minutes: 5)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.fail,
);
});
test('does nothing before the start time', () {
expect(
decideRecordingAction(
now: now,
startTime: now.add(const Duration(minutes: 30)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.none,
);
});
test('second overlapping recording starts when maxConcurrent is 2', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 1,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
});
group('decideOrphanAction', () {
test('resumes while the window is still open', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(minutes: 30)),
hasFile: true,
),
OrphanAction.resume,
);
});
test('resumes even without a file (crash before the first byte)', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(minutes: 30)),
hasFile: false,
),
OrphanAction.resume,
);
});
test('keeps a partial file when the window has closed', () {
expect(
decideOrphanAction(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
hasFile: true,
),
OrphanAction.finish,
);
});
test('fails when the window has closed with nothing captured', () {
expect(
decideOrphanAction(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
hasFile: false,
),
OrphanAction.fail,
);
});
test('does not resume for the last seconds of the window', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(seconds: 20)),
hasFile: true,
),
OrphanAction.finish,
);
});
});
group('decidePostExitAction', () {
test('retries when ffmpeg dies mid-window', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 1,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.retry,
);
});
test('retries on a clean exit too (upstream ended early)', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 0,
consecutiveFailures: 2,
hasFile: true,
),
PostExitAction.retry,
);
});
test('stops retrying once the attempt budget is spent', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 1,
consecutiveFailures: maxFfmpegAttempts - 1,
hasFile: true,
),
PostExitAction.complete,
);
});
test('completes at the end of the window', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 0,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.complete,
);
});
test('completes when ffmpeg errored but a file was captured', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 1,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.complete,
);
});
test('fails when nothing was captured and the window is over', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 1,
consecutiveFailures: 0,
hasFile: false,
),
PostExitAction.fail,
);
});
});
group('captureDuration', () {
test('uses the time left until the scheduled end, not the planned length',
() {
// Démarrage avec 2 minutes de retard sur une fenêtre d'une heure.
expect(
captureDuration(
now: now,
endTime: now.add(const Duration(minutes: 58)),
),
const Duration(minutes: 58),
);
});
test('never asks ffmpeg for a zero or negative duration', () {
expect(
captureDuration(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
),
const Duration(seconds: 30),
);
});
});
group('ffmpegRetryDelay', () {
test('backs off on repeated failures and caps at 30s', () {
expect(ffmpegRetryDelay(1), const Duration(seconds: 3));
expect(ffmpegRetryDelay(2), const Duration(seconds: 6));
expect(ffmpegRetryDelay(3), const Duration(seconds: 12));
expect(ffmpegRetryDelay(4), const Duration(seconds: 24));
expect(ffmpegRetryDelay(5), const Duration(seconds: 30));
expect(ffmpegRetryDelay(20), const Duration(seconds: 30));
});
});
}
-40
View File
@@ -1,40 +0,0 @@
import 'package:test/test.dart';
import '../utils/safe_path.dart';
void main() {
group('SafePath.resolveWithin', () {
const base = '/app/recordings';
test('accepts files inside the base directory', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/show.log'),
isNotNull,
);
expect(SafePath.resolveWithin(base, 'show.log'), isNotNull);
expect(
SafePath.resolveWithin(base, '/app/recordings/sub/dir/file.log'),
isNotNull,
);
});
test('rejects .. traversal', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/../data/xtremflow.db'),
isNull,
);
expect(SafePath.resolveWithin(base, '../../etc/passwd'), isNull);
});
test('rejects absolute paths outside the base', () {
expect(SafePath.resolveWithin(base, '/etc/passwd'), isNull);
expect(SafePath.resolveWithin(base, '/app/data/xtremflow.db'), isNull);
});
test('rejects sibling directories with a shared prefix', () {
expect(
SafePath.resolveWithin(base, '/app/recordings-evil/file.log'),
isNull,
);
});
});
}
-29
View File
@@ -1,29 +0,0 @@
import 'package:test/test.dart';
import '../api/proxy_handler.dart';
void main() {
group('isForbiddenProxyHost', () {
test('blocks loopback', () {
expect(isForbiddenProxyHost('127.0.0.1'), isTrue);
expect(isForbiddenProxyHost('localhost'), isTrue);
expect(isForbiddenProxyHost('::1'), isTrue);
});
test('blocks private LAN ranges', () {
expect(isForbiddenProxyHost('10.0.0.5'), isTrue);
expect(isForbiddenProxyHost('172.16.0.1'), isTrue);
expect(isForbiddenProxyHost('172.31.255.255'), isTrue);
expect(isForbiddenProxyHost('192.168.1.10'), isTrue);
});
test('blocks link-local / cloud metadata range', () {
expect(isForbiddenProxyHost('169.254.169.254'), isTrue);
});
test('allows public IPs and hostnames', () {
expect(isForbiddenProxyHost('8.8.8.8'), isFalse);
expect(isForbiddenProxyHost('172.32.0.1'), isFalse);
expect(isForbiddenProxyHost('cdn.example.com'), isFalse);
});
});
}
-104
View File
@@ -1,104 +0,0 @@
import 'package:test/test.dart';
import '../services/xmltv_epg_service.dart';
/// Extrait représentatif d'un dump XMLTV public : décalage horaire explicite,
/// identifiant ponctué côté source là où le panneau annonce `France2.fr`, et
/// un `display-name` distinct de l'identifiant.
String _fixture(DateTime start, DateTime stop) {
String stamp(DateTime d) {
final u = d.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year}${two(u.month)}${two(u.day)}'
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
}
return '''
<?xml version="1.0" encoding="utf-8" ?>
<tv>
<channel id="France.2.fr"><display-name>FR - FRANCE 2</display-name></channel>
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France.2.fr">
<title lang="fr">Journal de 20h</title>
<desc lang="fr">L'info du soir</desc>
</programme>
</tv>
''';
}
void main() {
final service = XmltvEpgService(sourceUrls: const []);
group('normalizeKey', () {
test('rapproche les identifiants ponctués différemment', () {
// Le cœur du repli : sans cette normalisation, `France2.fr` du panneau
// et `France.2.fr` du dump ne se rencontrent jamais.
expect(
XmltvEpgService.normalizeKey('France2.fr'),
XmltvEpgService.normalizeKey('France.2.fr'),
);
});
test('replie la casse et les accents', () {
expect(XmltvEpgService.normalizeKey('Chérie 25'), 'cherie25');
});
test('rend une clé vide sur une entrée nulle ou sans caractère utile', () {
expect(XmltvEpgService.normalizeKey(null), '');
expect(XmltvEpgService.normalizeKey('...'), '');
});
});
group('parseXmltvDate', () {
test('applique le décalage horaire annoncé', () {
expect(
XmltvEpgService.parseXmltvDate('20260811200000 +0200'),
DateTime.utc(2026, 8, 11, 18),
);
});
test('traite une date sans décalage comme de l UTC', () {
expect(
XmltvEpgService.parseXmltvDate('20260811200000'),
DateTime.utc(2026, 8, 11, 20),
);
});
test('rejette une valeur tronquée', () {
expect(XmltvEpgService.parseXmltvDate('202608'), isNull);
});
});
group('parse', () {
test('indexe un programme en cours et décode son titre', () {
final now = DateTime.now().toUtc();
final index = service.parseForTest(
_fixture(
now.subtract(const Duration(minutes: 10)),
now.add(const Duration(minutes: 20)),
),
);
final programmes = index[XmltvEpgService.normalizeKey('France2.fr')];
expect(programmes, isNotNull);
expect(programmes!.single.title, 'Journal de 20h');
expect(programmes.single.description, "L'info du soir");
});
test('rend la chaîne atteignable par son nom affiché', () {
final now = DateTime.now().toUtc();
final index = service.parseForTest(
_fixture(now, now.add(const Duration(minutes: 20))),
);
expect(index[XmltvEpgService.normalizeKey('FR - FRANCE 2')], isNotNull);
});
test('écarte les programmes terminés hors fenêtre de rétention', () {
final old = DateTime.now().toUtc().subtract(const Duration(days: 2));
final index = service.parseForTest(
_fixture(old, old.add(const Duration(minutes: 30))),
);
expect(index, isEmpty);
});
});
}
-29
View File
@@ -1,29 +0,0 @@
/// Redacts IPTV credentials from URLs before they reach logs.
///
/// Xtream Codes embeds credentials both in query strings
/// (`?username=u&password=p`) and in path segments
/// (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
class LogRedactor {
static final RegExp _queryCreds = RegExp(
r'(username|password)=[^&\s]*',
caseSensitive: false,
);
static final RegExp _pathCreds = RegExp(
r'/(live|movie|series)/[^/\s]+/[^/\s]+/',
caseSensitive: false,
);
/// Returns [url] with credentials replaced by `***`.
static String redactUrl(String url) {
var redacted = url.replaceAllMapped(
_queryCreds,
(m) => '${m.group(1)}=***',
);
redacted = redacted.replaceAllMapped(
_pathCreds,
(m) => '/${m.group(1)}/***/***/',
);
return redacted;
}
}
-35
View File
@@ -1,35 +0,0 @@
import 'dart:convert';
import 'package:bcrypt/bcrypt.dart';
import 'package:crypto/crypto.dart';
class PasswordHasher {
/// Cost factor 10: ~100-300ms per hash in pure Dart, acceptable for login frequency.
static const int _bcryptRounds = 10;
/// Legacy hashes are unsalted SHA-256 hex digests (64 hex chars).
static final RegExp _legacyPattern = RegExp(r'^[0-9a-f]{64}$');
/// Hash a password using bcrypt (salt embedded in the result).
static String hash(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptRounds));
}
/// Verify a password against a stored hash.
/// Supports legacy unsalted SHA-256 hashes for accounts created before
/// the bcrypt migration; those are rehashed lazily on successful login.
static bool verify(String password, String hash) {
if (isLegacy(hash)) {
final digest = sha256.convert(utf8.encode(password)).toString();
return digest == hash;
}
try {
return BCrypt.checkpw(password, hash);
} catch (_) {
// Malformed hash (neither legacy SHA-256 nor valid bcrypt)
return false;
}
}
/// True if the stored hash uses the legacy unsalted SHA-256 scheme.
static bool isLegacy(String hash) => _legacyPattern.hasMatch(hash);
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:path/path.dart' as p;
/// Path validation helpers to prevent directory traversal.
class SafePath {
/// Resolves [candidate] and returns it only if it stays inside [baseDir].
/// Returns null when the path escapes the base directory (e.g. via `..`
/// segments or an absolute path pointing elsewhere).
static String? resolveWithin(String baseDir, String candidate) {
final base = p.normalize(p.absolute(baseDir));
final resolved = p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(base, candidate),
);
if (resolved == base || p.isWithin(base, resolved)) {
return resolved;
}
return null;
}
}
+4 -3
View File
@@ -17,10 +17,11 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Flutter build failed"; exit 1 }
Write-Host "📂 Copying Web Assets..."
Copy-Item -Recurse "build\web" "dist\web"
# 4. Compile Dart Server
# 4. Compile Rust Server
Write-Host "🔨 Compiling Backend Server..." -ForegroundColor Yellow
dart compile exe bin/server.dart -o dist/xtremflow_server.exe
if ($LASTEXITCODE -ne 0) { Write-Error "Dart compile failed"; exit 1 }
cargo build --release --manifest-path server/Cargo.toml
if ($LASTEXITCODE -ne 0) { Write-Error "Rust compile failed"; exit 1 }
Copy-Item "server\target\release\xtremflow-server.exe" "dist\xtremflow_server.exe"
# 5. Create Start Script
Write-Host "📝 Creating Launcher Script..."
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- MAX_CONCURRENT_RECORDINGS=2
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
# aucun programme actuel. Vider la variable coupe tout appel sortant.
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz
- NVIDIA_GPU=true
volumes:
- xtremflow-data:/app/data
+5 -2
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:-}
@@ -24,7 +27,7 @@ services:
# aucun programme couvrant l'instant présent. Vider la variable coupe
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
# Plusieurs dumps se séparent par des virgules.
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz}
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
- NVIDIA_GPU=${NVIDIA_GPU:-false}
restart: unless-stopped
File renamed without changes.
+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`.
+20
View File
@@ -0,0 +1,20 @@
/// URL du logo d'une chaîne, servie par le backend (`/api/logo`).
///
/// Le backend tente d'abord [streamIcon] (l'URL fournie par le panneau), puis
/// cherche un logo par [name] quand l'hébergeur de picons du revendeur ne
/// répond pas. Le nom est donc transmis même sans `stream_icon` : c'est
/// souvent le seul moyen d'obtenir un logo.
///
/// Une chaîne sans logo trouvé reçoit un statut d'erreur : l'`errorBuilder`
/// de l'`Image.network` appelante affiche alors son propre repli.
String channelLogoUrl({required String streamIcon, required String name}) {
final icon = streamIcon.trim();
return Uri(
path: '/api/logo',
queryParameters: {
if (icon.startsWith('http://') || icon.startsWith('https://'))
'src': icon,
'name': name,
},
).toString();
}
+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,
+404 -102
View File
@@ -61,6 +61,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool _isMuted = false;
bool _ignoreStatusUpdates = false;
/// Fin de la plage réellement navigable, en temps absolu.
///
/// Un enregistrement est transcodé au fil de l'eau par FFmpeg : ce qui est
/// au-delà de ce point n'existe pas encore côté serveur. Sauter dedans
/// suppose de relancer l'encodeur à la position visée.
double _seekableEnd = 0;
/// Décalage du média courant par rapport au début du contenu : un
/// enregistrement rouvert à 45 min est transcodé *depuis* 45 min.
double _mediaOffset = 0;
/// Vitesse de lecture (hors direct).
double _rate = 1.0;
/// Un rechargement du flux à une nouvelle position est en cours : les
/// positions remontées par l'ancienne iframe ne doivent plus rien écraser.
bool _seekReloading = false;
/// Incrémenté à chaque (re)création de l'iframe : deux vues de plateforme
/// ne peuvent pas partager le même identifiant, et il faut un identifiant
/// neuf pour que Flutter reconstruise réellement l'iframe.
int _viewGeneration = 0;
/// Reflète l'état plein écran du document. Le navigateur peut en sortir
/// sans passer par notre bouton (touche Échap), d'où l'écoute de
/// `fullscreenchange` plutôt qu'un simple booléen basculé au clic.
@@ -113,6 +136,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
/// tous les navigateurs visés.
bool get _documentIsFullscreen => html.document.fullscreenElement != null;
/// Clé de reprise. Les enregistrements sont préfixés : leur identifiant est
/// un UUID stocké dans le même espace de noms que les identifiants VOD.
String get _positionKey => widget.streamType == StreamType.recording
? 'rec_${widget.streamId}'
: widget.streamId;
/// La position peut-elle avoir un sens (barre de progression, reprise) ?
bool get _isSeekable => widget.streamType != StreamType.live;
void _syncFullscreenState() {
final value = _documentIsFullscreen;
if (mounted && value != _isFullscreen) {
@@ -139,9 +171,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}
// Stable view ID for the instance to prevent iframe reload on rebuild
// (orientation change). Includes quality so a quality switch forces a
// fresh iframe.
_viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix';
// (orientation change). La génération change à chaque appel : sans
// elle, relancer le flux à une autre position réutiliserait la même
// vue de plateforme et l'iframe ne serait jamais reconstruite.
_viewId =
'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix-'
'${_viewGeneration++}';
final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = '';
@@ -166,7 +201,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
quality: _quality.value,
);
} else if (widget.streamType == StreamType.recording) {
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
// FFmpeg transcode l'enregistrement séquentiellement : reprendre à
// 45 min sans le lui dire imposerait d'attendre qu'il y arrive.
// `?start=` lui fait démarrer l'encodage à la position demandée.
_mediaOffset = (startTimeOverride ?? 0) > 0
? startTimeOverride!.floorToDouble()
: 0;
final base = '${service.backendBaseUrl}/api/recordings/stream/'
'$currentStreamId/playlist.m3u8';
streamUrl =
_mediaOffset > 0 ? '$base?start=${_mediaOffset.toInt()}' : base;
}
// Store URL for Lite Player
@@ -190,24 +234,24 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// - Live TV: Player Lite (simple TS playback with mpegts.js)
// - VOD/Series: Player Standard (fuller controls, HLS support)
// Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam =
widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag
// Plus de cache-buster « &v=timestamp » : il forçait le re-téléchargement
// et le re-parse de player.html à CHAQUE zap. Le serveur sert désormais
// les .html en no-cache, la fraîcheur est garantie sans pénaliser le zap.
var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true';
if (widget.streamType == StreamType.recording) {
playerSrc += '&is_recording=true';
}
if (startTimeOverride != null) {
// Le média commence à l'offset : le lecteur ré-ajoute ce décalage
// pour exprimer position et recherches en temps absolu.
if (_mediaOffset > 0) playerSrc += '&offset=${_mediaOffset.toInt()}';
} else if (startTimeOverride != null && startTimeOverride > 0) {
playerSrc += '&t=$startTimeOverride';
}
@@ -231,12 +275,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
setState(() {
_isInitialized = true;
_isLoading = false;
_isPlaying = true;
_seekReloading = false;
// La nouvelle iframe repart d'une plage navigable vide, qui
// grandira au rythme du transcodage.
_seekableEnd = _mediaOffset;
if (_mediaOffset > 0) _currentPosition = _mediaOffset;
});
// Force unmute on load (after a short delay to ensure iframe is ready)
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_sendMessage({'type': 'set_volume', 'value': 1.0});
if (!mounted) return;
_sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0});
if (_rate != 1.0) {
_sendMessage({'type': 'set_rate', 'value': _rate});
}
});
}
@@ -259,26 +311,47 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final type = data['type'];
if (type == 'playback_position') {
// Un rechargement à une autre position est en cours : l'ancienne
// iframe continue d'émettre quelques instants, ne pas la laisser
// ramener la barre en arrière.
if (_seekReloading) return;
final currentTime = (data['currentTime'] as num).toDouble();
final rawDuration = (data['duration'] as num).toDouble();
final duration = rawDuration.isFinite ? rawDuration : 0.0;
final rawSeekable = data['seekableEnd'];
final seekableEnd =
rawSeekable is num ? rawSeekable.toDouble() : currentTime;
// Only update position if user is NOT dragging the slider
if (!_isSeeking) {
setState(() {
setState(() {
if (!_isSeeking) {
_currentPosition = currentTime;
_totalDuration = duration > 0 ? duration : 1;
});
}
}
if (seekableEnd.isFinite && seekableEnd > 0) {
_seekableEnd = seekableEnd;
}
});
// Update watch history if relevant
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,
_positionKey,
currentTime,
duration,
);
}
} else if (type == 'seek_out_of_range') {
// Cible hors de la zone déjà transcodée : seul un redémarrage du
// flux à cet instant peut la servir.
final target = (data['value'] as num?)?.toDouble();
if (target != null) _restartAt(target);
} else if (type == 'playback_ended') {
setState(() => _isPlaying = false);
ref.read(playbackPositionsProvider.notifier).clearPosition(_positionKey);
} else if (type == 'playback_status') {
if (!_ignoreStatusUpdates) {
setState(() => _isPlaying = data['status'] == 'playing');
@@ -344,6 +417,48 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}
}
/// Recherche à un instant absolu du contenu.
///
/// Le lecteur tranche lui-même : saut local s'il a la donnée, sinon il
/// répond `seek_out_of_range` et le flux est relancé à cet instant.
void _seekTo(double target) {
final clamped = target.clamp(0.0, _totalDuration);
setState(() => _currentPosition = clamped);
_sendMessage({'type': 'seek', 'value': clamped});
_onHover();
}
void _seekBy(double delta) => _seekTo(_currentPosition + delta);
/// Relance le flux à [target] : nouvelle session de transcodage côté
/// serveur, donc quelques secondes d'attente — réservé aux sauts que la
/// zone déjà transcodée ne couvre pas.
void _restartAt(double target) {
if (!_isSeekable || _seekReloading) return;
// Jamais tout à la fin : FFmpeg lancé au-delà de la dernière image
// n'encoderait rien et le lecteur n'aurait qu'une erreur à afficher.
final ceiling =
_totalDuration > 10 ? _totalDuration - 5 : _totalDuration;
final clamped = target.clamp(0.0, ceiling);
if (widget.streamType != StreamType.recording) {
// VOD/séries : le serveur ne sait pas démarrer ailleurs qu'au début,
// on se contente du bord de la zone disponible.
_sendMessage({'type': 'seek', 'value': _seekableEnd});
return;
}
setState(() {
_seekReloading = true;
_currentPosition = clamped;
});
_initializePlayer(startTimeOverride: clamped, isChannelSwitch: true);
}
void _setRate(double rate) {
setState(() => _rate = rate);
_sendMessage({'type': 'set_rate', 'value': rate});
_onHover();
}
void _toggleFullscreen() {
try {
if (_documentIsFullscreen) {
@@ -378,6 +493,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// current position, live restarts at the live edge.
final resumeAt =
widget.streamType == StreamType.live ? null : _currentPosition;
// Un enregistrement redémarre à cette position côté serveur : sa zone
// navigable repart de zéro.
_initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true);
}
@@ -407,17 +524,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}
}
/// Sauts proposés par les raccourcis et les boutons.
static const _smallStep = 10.0;
static const _bigStep = 60.0;
/// Vitesses de lecture proposées hors direct.
static const _rates = <double>[0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
/// Keyboard / TV remote controls:
/// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live),
/// ↑/↓ = zap (live), M = mute, Esc = exit player.
/// space-enter-K = play/pause, ←/→ = seek ±10 s (VOD) or zap (live),
/// Maj+←/→ ou J/L = ±60 s, ↑/↓ = zap (live), 0-9 = saut en % du contenu,
/// M = mute, F = plein écran, +/- = vitesse, Esc = exit player.
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isLive = widget.streamType == StreamType.live;
final key = event.logicalKey;
final shift = HardwareKeyboard.instance.isShiftPressed;
if (key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.keyK ||
key == LogicalKeyboardKey.mediaPlayPause) {
_togglePlayPause();
_onHover();
@@ -432,28 +559,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyF) {
_toggleFullscreen();
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
if (isLive) {
_previousChannel();
_onHover();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition - 10).clamp(0, _totalDuration),
});
_seekBy(shift ? -_bigStep : -_smallStep);
}
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
if (isLive) {
_nextChannel();
_onHover();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition + 10).clamp(0, _totalDuration),
});
_seekBy(shift ? _bigStep : _smallStep);
}
_onHover();
return KeyEventResult.handled;
}
if (isLive && key == LogicalKeyboardKey.arrowUp) {
@@ -466,9 +592,68 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_onHover();
return KeyEventResult.handled;
}
if (!isLive) {
if (key == LogicalKeyboardKey.keyJ) {
_seekBy(-_bigStep);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyL) {
_seekBy(_bigStep);
return KeyEventResult.handled;
}
// 0-9 : saut direct au dixième correspondant du contenu.
final decile = _decileFor(key);
if (decile != null) {
_seekTo(_totalDuration * decile / 10);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.period) {
_stepRate(1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.comma) {
_stepRate(-1);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
/// Touche 0-9 (rangée du haut ou pavé numérique) → dixième visé.
/// Table non `const` : `LogicalKeyboardKey` redéfinit `==`, ce qui interdit
/// une table constante.
static final Map<LogicalKeyboardKey, int> _digitKeys = {
LogicalKeyboardKey.digit0: 0,
LogicalKeyboardKey.digit1: 1,
LogicalKeyboardKey.digit2: 2,
LogicalKeyboardKey.digit3: 3,
LogicalKeyboardKey.digit4: 4,
LogicalKeyboardKey.digit5: 5,
LogicalKeyboardKey.digit6: 6,
LogicalKeyboardKey.digit7: 7,
LogicalKeyboardKey.digit8: 8,
LogicalKeyboardKey.digit9: 9,
LogicalKeyboardKey.numpad0: 0,
LogicalKeyboardKey.numpad1: 1,
LogicalKeyboardKey.numpad2: 2,
LogicalKeyboardKey.numpad3: 3,
LogicalKeyboardKey.numpad4: 4,
LogicalKeyboardKey.numpad5: 5,
LogicalKeyboardKey.numpad6: 6,
LogicalKeyboardKey.numpad7: 7,
LogicalKeyboardKey.numpad8: 8,
LogicalKeyboardKey.numpad9: 9,
};
int? _decileFor(LogicalKeyboardKey key) => _digitKeys[key];
void _stepRate(int direction) {
final index = _rates.indexOf(_rate);
final next = (index < 0 ? _rates.indexOf(1.0) : index) + direction;
if (next < 0 || next >= _rates.length) return;
_setRate(_rates[next]);
}
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = twoDigits(d.inHours);
@@ -747,9 +932,28 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Center Loading Indicator (only show when loading)
if (_isLoading)
const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(
color: AppColors.primary,
),
// Un saut hors de la zone transcodée relance
// l'encodeur : quelques secondes d'attente,
// autant dire pourquoi.
if (_seekReloading) ...[
const SizedBox(height: 16),
Text(
'Reprise à '
'${_formatDuration(Duration(seconds: _currentPosition.toInt()))}…',
style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant,
fontSize: 14,
),
),
],
],
),
),
@@ -786,6 +990,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
activeTrackColor: AppColors.primary,
inactiveTrackColor:
AppColors.onSurface.withOpacity(0.2),
// Portion déjà transcodée : navigable
// instantanément, contrairement au
// reste qui redémarre l'encodeur.
secondaryActiveTrackColor:
AppColors.onSurface.withOpacity(0.4),
thumbColor: AppColors.onSurface,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8,
@@ -794,7 +1003,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
AppColors.primary.withOpacity(0.2),
),
child: Slider(
value: _currentPosition,
value: _currentPosition
.clamp(0.0, _totalDuration),
secondaryTrackValue: _seekableEnd > 0
? _seekableEnd
.clamp(0.0, _totalDuration)
: null,
min: 0,
max: _totalDuration,
onChanged: (val) => setState(
@@ -803,9 +1017,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onChangeStart: (_) =>
setState(() => _isSeeking = true),
onChangeEnd: (val) {
_sendMessage(
{'type': 'seek', 'value': val},
);
_seekTo(val);
Future.delayed(
const Duration(milliseconds: 500),
() {
@@ -835,68 +1047,91 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
const SizedBox(height: 12),
// Buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildGlassIconButton(
icon: Icons.replay_10_rounded,
onTap: () => _sendMessage({
'type': 'seek',
'value': (_currentPosition - 10)
.clamp(0, _totalDuration),
}),
transparent: true,
tooltip: 'Reculer de 10 s',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: _isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: 56,
iconSize: 32,
tooltip: _isPlaying ? 'Pause' : 'Lecture',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: Icons.forward_10_rounded,
onTap: () => _sendMessage({
'type': 'seek',
'value': (_currentPosition + 10)
.clamp(0, _totalDuration),
}),
transparent: true,
tooltip: 'Avancer de 10 s',
),
const Spacer(),
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isMuted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
onTap: _toggleMute,
transparent: true,
tooltip: _isMuted
? 'Activer le son'
: 'Couper le son',
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isFullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onTap: _toggleFullscreen,
transparent: true,
tooltip: _isFullscreen
? 'Quitter le plein écran'
: 'Plein écran',
),
],
LayoutBuilder(
builder: (context, constraints) {
// Sous 720 px, les sauts de 60 s et le
// sélecteur de vitesse sortent de la barre
// plutôt que de la faire déborder.
final compact = constraints.maxWidth < 720;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!compact) ...[
_buildGlassIconButton(
icon: Icons.fast_rewind_rounded,
onTap: () => _seekBy(-_bigStep),
transparent: true,
tooltip: 'Reculer de 1 min',
),
const SizedBox(width: 16),
],
_buildGlassIconButton(
icon: Icons.replay_10_rounded,
onTap: () => _seekBy(-_smallStep),
transparent: true,
tooltip: 'Reculer de 10 s',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: _isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: 56,
iconSize: 32,
tooltip:
_isPlaying ? 'Pause' : 'Lecture',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: Icons.forward_10_rounded,
onTap: () => _seekBy(_smallStep),
transparent: true,
tooltip: 'Avancer de 10 s',
),
if (!compact) ...[
const SizedBox(width: 16),
_buildGlassIconButton(
icon: Icons.fast_forward_rounded,
onTap: () => _seekBy(_bigStep),
transparent: true,
tooltip: 'Avancer de 1 min',
),
],
const Spacer(),
if (!compact) ...[
_buildRateSelector(),
const SizedBox(width: 16),
],
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isMuted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
onTap: _toggleMute,
transparent: true,
tooltip: _isMuted
? 'Activer le son'
: 'Couper le son',
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isFullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onTap: _toggleFullscreen,
transparent: true,
tooltip: _isFullscreen
? 'Quitter le plein écran'
: 'Plein écran',
),
],
);
},
),
],
),
@@ -913,6 +1148,73 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
);
}
/// Sélecteur de vitesse de lecture — utile pour parcourir un
/// enregistrement long sans sauter à l'aveugle.
Widget _buildRateSelector() {
return Tooltip(
message: 'Vitesse de lecture',
child: PopupMenuButton<double>(
initialValue: _rate,
onSelected: _setRate,
color: AppColors.surfaceContainer,
tooltip: '',
itemBuilder: (context) => [
for (final rate in _rates)
PopupMenuItem<double>(
value: rate,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
rate == _rate
? Icons.check_rounded
: Icons.speed_rounded,
size: 16,
color: rate == _rate
? AppColors.primary
: AppColors.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
_formatRate(rate),
style: TextStyle(
color: rate == _rate
? AppColors.primary
: AppColors.onSurface,
),
),
],
),
),
],
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.onSurface.withOpacity(0.15)),
),
child: Text(
_formatRate(_rate),
style: GoogleFonts.karla(
color: _rate == 1.0 ? AppColors.onSurface : AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
/// « 1× », « 1.5× » — sans décimale inutile.
static String _formatRate(double rate) {
final text = rate == rate.roundToDouble()
? rate.toStringAsFixed(0)
: rate.toString();
return '$text×';
}
Widget _buildGlassIconButton({
required IconData icon,
required VoidCallback onTap,
@@ -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,
),
),
);
+17 -4
View File
@@ -176,8 +176,11 @@ class XtreamService {
/// Faster zapping on compatible players (mpegts.js)
String getLiveStreamUrlTs(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Backend route injects the Xtream credentials server-side
return '$_backendBaseUrl/api/live/$streamId.ts';
// Route « turbo » : vidéo copiée, audio réencodé en AAC côté serveur.
// Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio
// d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes
// muettes. Il reste utilisé tel quel par le scheduler d'enregistrement.
return '$_backendBaseUrl/api/live/$streamId/turbo.ts';
}
/// Generate stream URL for VOD (movies)
@@ -188,7 +191,16 @@ class XtreamService {
String quality = 'high',
}) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8';
// `ext` : le serveur demande le fichier au panneau sous son extension
// réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551).
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'
'${_extQuery(containerExtension, first: true)}';
}
String _extQuery(String ext, {required bool first}) {
final value = ext.trim().toLowerCase();
if (value.isEmpty) return '';
return '${first ? '?' : '&'}ext=${Uri.encodeQueryComponent(value)}';
}
/// Generate stream URL for series episodes
@@ -198,7 +210,8 @@ class XtreamService {
String quality = 'high',
}) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series';
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series'
'${_extQuery(containerExtension, first: false)}';
}
/// Authenticate and get server info
+75 -29
View File
@@ -5,6 +5,7 @@ import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart';
import '../screens/player_screen.dart';
import '../../../core/api/channel_logo.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
@@ -63,8 +64,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();
@@ -397,25 +413,26 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
padding: const EdgeInsets.all(16),
child: Center(
child: channel.streamIcon.isNotEmpty
? Image.network(
_getProxiedIconUrl(channel.streamIcon)!,
cacheWidth: 160,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => Icon(
Icons.tv,
color: AppColors.onSurface.withOpacity(0.3),
size: 40,
),
)
: Text(
channel.name.characters.first.toUpperCase(),
style: GoogleFonts.fraunces(
fontSize: 40,
fontWeight: FontWeight.bold,
color: AppColors.onSurface24,
),
),
// Toujours demandé au backend, même sans
// stream_icon : il cherche alors un logo par nom.
child: Image.network(
channelLogoUrl(
streamIcon: channel.streamIcon,
name: channel.name,
),
cacheWidth: 160,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => Text(
channel.name.isEmpty
? '?'
: channel.name.characters.first.toUpperCase(),
style: GoogleFonts.fraunces(
fontSize: 40,
fontWeight: FontWeight.bold,
color: AppColors.onSurface24,
),
),
),
),
),
),
@@ -515,6 +532,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,
@@ -547,14 +601,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
}
String? _getProxiedIconUrl(String originalUrl) {
if (originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playChannel(Channel channel, List<Channel> contextList) {
Navigator.of(context).push(
MaterialPageRoute(
@@ -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é !')),
+395 -75
View File
@@ -1,17 +1,26 @@
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/api/channel_logo.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/recordings_refresh.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
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 +31,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 +56,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(),
],
),
),
],
),
@@ -309,25 +369,25 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
),
child: Row(
children: [
ch.streamIcon.isNotEmpty
? Image.network(
ch.streamIcon,
width: 24,
height: 16,
cacheWidth: 64,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
)
: const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
// URL panneau brute auparavant : en
// http depuis une page https, le
// navigateur la bloquait.
Image.network(
channelLogoUrl(
streamIcon: ch.streamIcon,
name: ch.name,
),
width: 24,
height: 16,
cacheWidth: 64,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -592,17 +652,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(
@@ -722,48 +782,95 @@ class _ProgrammeCard extends StatelessWidget {
// ONGLET 2 — ENREGISTREMENTS
// ═══════════════════════════════════════════════════════════════════════════
class _RecordingsListView extends StatefulWidget {
class _RecordingsListView extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const _RecordingsListView({required this.playlist});
@override
State<_RecordingsListView> createState() => _RecordingsListViewState();
ConsumerState<_RecordingsListView> createState() =>
_RecordingsListViewState();
}
class _RecordingsListViewState extends State<_RecordingsListView> {
class _RecordingsListViewState extends ConsumerState<_RecordingsListView> {
List<dynamic> _recordings = [];
bool _isLoading = true;
String? _error;
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,11 +883,72 @@ 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();
}
/// Clé de reprise d'un enregistrement (même convention que le lecteur).
static String _resumeKey(String recordingId) => 'rec_$recordingId';
/// Position sauvegardée, ou 0 s'il n'y en a pas d'exploitable.
double _savedPosition(Map<String, dynamic> rec) {
final id = rec['id'] as String?;
if (id == null || id.isEmpty) return 0;
return ref.read(playbackPositionsProvider).getPosition(_resumeKey(id));
}
/// Durée du média : celle mesurée par le serveur si disponible, sinon la
/// durée programmée. Un enregistrement arrêté en avance est plus court que
/// `end_time - start_time`.
Duration? _recordingDuration(Map<String, dynamic> rec) {
final probed = rec['duration_seconds'];
if (probed is num && probed > 0) {
return Duration(seconds: probed.round());
}
try {
if (rec['start_time'] != null && rec['end_time'] != null) {
final start = DateTime.parse(rec['start_time'].toString());
final end = DateTime.parse(rec['end_time'].toString());
final scheduled = end.difference(start);
if (scheduled > Duration.zero) return scheduled;
}
} catch (_) {}
return null;
}
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
final recordingId = rec['id'] as String?;
final title = rec['title'] as String? ?? 'Enregistrement';
@@ -792,17 +960,52 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
return;
}
Duration? recordingDuration;
try {
if (rec['start_time'] != null && rec['end_time'] != null) {
final start = DateTime.parse(rec['start_time'].toString());
final end = DateTime.parse(rec['end_time'].toString());
recordingDuration = end.difference(start);
final recordingDuration = _recordingDuration(rec);
final saved = _savedPosition(rec);
// Reprise proposée, jamais imposée : un enregistrement déjà vu se
// relance parfois depuis le début.
double? startAt;
if (saved > 30) {
if (!mounted) return;
final resume = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainer,
title: Text(
'Reprendre la lecture ?',
style: GoogleFonts.fraunces(color: AppColors.onSurface),
),
content: Text(
'« $title » a été interrompu à '
'${PlaybackPositionsNotifier.formatTime(saved)}.',
style: const TextStyle(color: AppColors.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Depuis le début'),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: const Text('Reprendre'),
),
],
),
);
if (resume == null) return; // dialogue fermé : on n'ouvre rien
if (resume) {
startAt = saved;
} else {
ref
.read(playbackPositionsProvider.notifier)
.clearPosition(_resumeKey(recordingId));
}
} catch (_) {}
}
if (!mounted) return;
Navigator.of(context).push(
await Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) => PlayerScreen(
streamId: recordingId,
@@ -811,9 +1014,51 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
streamType: StreamType.recording,
containerExtension: 'ts',
duration: recordingDuration,
startTime: startAt,
),
),
);
// Au retour du lecteur, la progression affichée dans la liste doit
// refléter la position atteinte.
if (mounted) setState(() {});
}
/// Barre « déjà vu jusqu'ici » sous un enregistrement entamé.
List<Widget> _buildResumeIndicator(Map<String, dynamic> rec) {
final saved = _savedPosition(rec);
if (saved <= 30) return const [];
final total = _recordingDuration(rec)?.inSeconds ?? 0;
final ratio = total > 0 ? (saved / total).clamp(0.0, 1.0) : null;
return [
const SizedBox(height: 6),
Row(
children: [
if (ratio != null)
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: AppColors.onSurface.withOpacity(0.1),
valueColor:
const AlwaysStoppedAnimation(AppColors.primary),
),
),
),
if (ratio != null) const SizedBox(width: 8),
Text(
'Reprendre à ${PlaybackPositionsNotifier.formatTime(saved)}',
style: const TextStyle(
color: AppColors.primary,
fontSize: 11,
),
),
],
),
];
}
Future<void> _showLogs(String id, String title) async {
@@ -865,6 +1110,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => AppColors.live,
'completed' => AppColors.success,
'failed' => AppColors.warning,
'cancelled' => AppColors.onSurface38,
_ => AppColors.primaryContainer,
};
@@ -873,9 +1119,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 {
@@ -889,6 +1146,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
@override
Widget build(BuildContext context) {
// Les positions de reprise arrivent de façon asynchrone (SharedPreferences)
// et changent au retour du lecteur : sans cette écoute, la mention
// « Reprendre à … » n'apparaîtrait qu'au prochain rafraîchissement.
ref.watch(playbackPositionsProvider);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -896,10 +1157,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 +1186,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 +1263,36 @@ 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 (status == 'completed')
..._buildResumeIndicator(rec),
if (rec['error_reason'] != null)
Text(
'⚠ ${rec['error_reason']}',
@@ -1030,13 +1343,18 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
),
if (status == 'completed')
IconButton(
icon: const Icon(
Icons.play_circle_outline,
icon: Icon(
_savedPosition(rec) > 30
? Icons.play_circle_fill
: Icons.play_circle_outline,
color: AppColors.success,
size: 20,
),
tooltip: 'Lecture',
onPressed: () => _playRecording(context, rec),
tooltip: _savedPosition(rec) > 30
? 'Reprendre'
: 'Lecture',
onPressed: () =>
_playRecording(context, rec),
),
IconButton(
icon: const Icon(
@@ -1057,8 +1375,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);
}
}
}
@@ -5,6 +5,7 @@ import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../features/iptv/providers/favorites_provider.dart';
import '../screens/mobile_player_screen.dart';
import '../../../../core/api/channel_logo.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
@@ -335,7 +336,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends StatelessWidget {
class _MobileChannelTile extends ConsumerWidget {
final Channel channel;
final VoidCallback onTap;
@@ -345,11 +346,14 @@ class _MobileChannelTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
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 = channelLogoUrl(
streamIcon: channel.streamIcon,
name: channel.name,
);
return GlassContainer.glass(
borderRadius: 12,
@@ -369,16 +373,14 @@ class _MobileChannelTile extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface24),
)
: const Icon(Icons.tv, color: AppColors.onSurface24),
child: Image.network(
iconUrl,
fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface24),
),
),
const SizedBox(width: 16),
Expanded(
@@ -393,6 +395,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
+1 -1
View File
@@ -4,7 +4,7 @@ publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
sdk: ^3.13.0
dependencies:
flutter:
+2063
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "xtremflow-server"
version = "0.1.0"
edition = "2024"
description = "Serveur XtremFlow : API, proxy Xtream, streaming FFmpeg, enregistrements TV"
publish = false
[dependencies]
async-stream = "0.3.6"
axum = "0.8.9"
base64 = "0.22"
bcrypt = "0.19.3"
bytes = "1.12.1"
chrono = "0.4.45"
flate2 = "1.1.10"
futures = "0.3.34"
libc = "0.2.190"
md-5 = "0.10"
mime_guess = "2.0.5"
percent-encoding = "2.3.2"
quick-xml = "0.37"
regex = "1.13.1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.10"
tokio = { version = "1.53.2", features = ["full"] }
tokio-util = { version = "0.7.20", features = ["io"] }
url = "2.5.8"
uuid = { version = "1.28.0", features = ["v4"] }
[profile.release]
lto = "thin"
strip = true
+384
View File
@@ -0,0 +1,384 @@
//! Comptes : authentification, utilisateurs (admin), playlists, réglages.
use crate::http::{self, Req, Resp, extract_token, json, json_as_text, segments, text};
use crate::state::SharedState;
use crate::util::password;
use serde_json::{Value, json};
fn error_json(status: u16, error: impl Into<String>) -> Resp {
json(status, &json!({"success": false, "error": error.into()}))
}
fn str_field(payload: &Value, key: &str) -> Option<String> {
payload.get(key).and_then(Value::as_str).map(str::to_string)
}
/// Corps JSON objet, ou réponse 500 (comportement de l'ancien serveur sur
/// un corps illisible).
fn parse_object(req: &Req) -> Result<Value, Resp> {
match serde_json::from_slice::<Value>(&req.body) {
Ok(v) if v.is_object() => Ok(v),
Ok(_) => Err(error_json(
500,
"type is not a subtype of type 'Map<String, dynamic>'",
)),
Err(e) => Err(error_json(500, format!("FormatException: {e}"))),
}
}
/// Longueur à la manière de Dart (unités UTF-16).
fn dart_len(s: &str) -> usize {
s.encode_utf16().count()
}
// ==================== /api/auth ====================
pub async fn auth(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["login"]) => login(state, req).await,
("POST", ["logout"]) => logout(state, req),
("GET" | "HEAD", ["me"]) => me(state, req),
_ => http::not_found(),
}
}
async fn login(state: &SharedState, req: &Req) -> Resp {
println!("[Auth] Login attempt received");
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => {
println!("[Auth] ERROR during login: invalid body");
return resp;
}
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
println!("[Auth] Missing username or password");
return error_json(400, "Username and password are required");
};
println!("[Auth] Verifying credentials for user: {username}");
// bcrypt est coûteux : hors du runtime asynchrone.
let db = state.db.clone();
let user_name = username.clone();
let user = tokio::task::spawn_blocking(move || db.verify_credentials(&user_name, &pass))
.await
.ok()
.flatten();
let Some(user) = user else {
println!("[Auth] Invalid credentials for user: {username}");
return error_json(401, "Invalid credentials");
};
println!(
"[Auth] User verified, creating session for userId: {}",
user.id
);
let session = match state.db.create_session(&user.id) {
Ok(s) => s,
Err(e) => return error_json(500, e.to_string()),
};
// Cookie de session HttpOnly : les requêtes média de même origine
// (hls.js dans l'iframe du lecteur ne peut pas envoyer d'en-tête
// Authorization) sont ainsi authentifiées.
let max_age = (session.expires_at.to_utc() - chrono::Utc::now()).num_seconds();
let mut resp = json(
200,
&json!({"success": true, "user": user.to_json(), "token": session.token}),
);
http::set_header(
&mut resp,
"set-cookie",
&format!(
"session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}",
session.token
),
);
resp
}
fn logout(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
state.db.delete_session(&token);
let mut resp = json(200, &json!({"success": true}));
http::set_header(
&mut resp,
"set-cookie",
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
resp
}
fn me(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
let Some(session) = state.db.find_session_by_token(&token) else {
return error_json(401, "Invalid or expired session");
};
match state.db.find_user_by_id(&session.user_id) {
Some(user) => json(200, &json!({"user": user.to_json()})),
None => error_json(404, "User not found"),
}
}
// ==================== /api/users ====================
fn is_admin(state: &SharedState, req: &Req) -> bool {
req.user_id
.as_deref()
.and_then(|id| state.db.find_user_by_id(id))
.is_some_and(|u| u.is_admin)
}
fn admin_required() -> Resp {
json_as_text(403, &json!({"success": false, "error": "Admin required"}))
}
pub async fn users(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list_users(state, req),
("POST", []) => create_user(state, req).await,
("PUT", [id, "password"]) => update_password(state, req, id).await,
("PUT", [id]) => update_user(state, req, id),
("DELETE", [id]) => delete_user(state, req, id),
_ => http::not_found(),
}
}
fn list_users(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let users: Vec<Value> = state
.db
.get_all_users()
.iter()
.map(|u| u.to_json())
.collect();
json(200, &json!({"success": true, "users": users}))
}
async fn create_user(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
return error_json(400, "Missing required fields");
};
let admin = payload
.get("isAdmin")
.and_then(Value::as_bool)
.unwrap_or(false);
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
if state.db.find_user_by_username(&username).is_some() {
return error_json(400, "Username already exists");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
match state.db.create_user(&username, &hash, admin) {
Ok(user) => json(200, &json!({"success": true, "user": user.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
async fn update_password(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let Some(pass) = str_field(&payload, "password") else {
return error_json(400, "Missing password");
};
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
state.db.update_password_hash(id, &hash);
json(200, &json!({"success": true}))
}
fn update_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
if let Some(admin) = payload.get("isAdmin").and_then(Value::as_bool)
&& let Err(e) = state.db.update_user_admin_status(id, admin)
{
return error_json(500, e.to_string());
}
json(200, &json!({"success": true}))
}
fn delete_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
// Interdit de se supprimer soi-même.
if req.user_id.as_deref() == Some(id) {
return error_json(400, "Cannot delete yourself");
}
match state.db.delete_user(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/playlists ====================
pub fn playlists(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return error_json(401, "Unauthorized");
};
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let list: Vec<Value> = state
.db
.get_playlists(&user_id)
.iter()
.map(|p| p.to_json())
.collect();
json(200, &json!({"playlists": list}))
}
("POST", []) => create_playlist(state, req, &user_id),
("PUT", [id]) => update_playlist(state, req, &user_id, id),
("DELETE", [id]) => delete_playlist(state, &user_id, id),
_ => http::not_found(),
}
}
fn create_playlist(state: &SharedState, req: &Req, user_id: &str) -> Resp {
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let (Some(name), Some(server_url), Some(username), Some(pass)) = (
field("name"),
field("serverUrl"),
field("username"),
field("password"),
) else {
return error_json(400, "Missing required fields");
};
let dns = field("dns").unwrap_or_else(|| server_url.clone());
match state
.db
.create_playlist(user_id, &name, &server_url, &username, &pass, Some(&dns))
{
Ok(p) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
fn update_playlist(state: &SharedState, req: &Req, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let name = field("name").unwrap_or(existing.name.clone());
let server_url = field("serverUrl").unwrap_or(existing.server_url.clone());
let username = field("username").unwrap_or(existing.username.clone());
// Mot de passe vide = « inchangé » : les clients ne reçoivent jamais le
// vrai mot de passe, les formulaires d'édition le renvoient vide.
let pass = field("password")
.filter(|p| !p.is_empty())
.unwrap_or(existing.password.clone());
let dns = field("dns").or(existing.dns.clone());
match state
.db
.update_playlist(id, &name, &server_url, &username, &pass, dns.as_deref())
{
Ok(Some(p)) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Ok(None) => error_json(404, "Playlist not found"),
Err(e) => error_json(500, e.to_string()),
}
}
fn delete_playlist(state: &SharedState, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
match state.db.delete_playlist(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/settings ====================
pub fn settings(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return json_as_text(403, &json!({"error": "Authentication required"}));
};
if !segments(rest).is_empty() {
return http::not_found();
}
match req.method.as_str() {
"GET" | "HEAD" => {
let settings = state
.db
.get_user_settings(&user_id)
.unwrap_or_else(|| "{}".to_string());
http::with_headers(
http::response(200, settings),
&[("content-type", "application/json")],
)
}
"POST" => {
let body = req.body_str();
if serde_json::from_str::<Value>(&body).is_err() {
return json_as_text(400, &json!({"error": "Invalid JSON"}));
}
if let Err(e) = state.db.update_user_settings(&user_id, &body) {
return text(
500,
json!({"error": format!("Failed to save settings: {e}")}).to_string(),
);
}
http::with_headers(
http::response(200, body),
&[("content-type", "application/json")],
)
}
_ => http::not_found(),
}
}
+194
View File
@@ -0,0 +1,194 @@
//! `/api/admin` : purge des temporaires et diagnostic du délai de zap.
use crate::http::{self, Req, Resp, json_as_text, segments};
use crate::models::PlaylistConfig;
use crate::state::SharedState;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
pub async fn admin(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["purge"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
http::json(200, &state.cleanup.run_cleanup().await)
}
("GET" | "HEAD", ["upstream-probe"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
let stream = req.query_param("stream").unwrap_or_default();
if stream.is_empty() || !stream.bytes().all(|b| b.is_ascii_digit()) {
return http::text(400, "stream=<id numérique> requis");
}
let Some(playlist) = state.get_playlist(req) else {
return http::text(403, "No playlist");
};
http::json(
200,
&probe_upstream(&state.http_manual, &playlist, &stream).await,
)
}
_ => http::not_found(),
}
}
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Reste à savoir lequel des deux formats
/// démarre le plus vite, et où part le temps. Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
///
/// [client] ne doit PAS suivre les redirections (chaque saut est chronométré).
pub async fn probe_upstream(
client: &reqwest::Client,
playlist: &PlaylistConfig,
stream_id: &str,
) -> Value {
let base = format!(
"{}/live/{}/{}/{stream_id}",
playlist.dns, playlist.username, playlist.password
);
let ts = match reqwest::Url::parse(&format!("{base}.ts")) {
Ok(url) => probe_stream(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
// Laisser le panneau libérer la connexion (comptes à une connexion).
tokio::time::sleep(Duration::from_secs(2)).await;
let hls = match reqwest::Url::parse(&format!("{base}.m3u8")) {
Ok(url) => probe_hls(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
json!({"stream": stream_id, "ts": ts, "m3u8": hls})
}
/// Suit les redirections à la main pour chronométrer chaque saut.
async fn open(
client: &reqwest::Client,
url: reqwest::Url,
started: Instant,
) -> Result<(Option<reqwest::Response>, Vec<Value>, reqwest::Url), String> {
let mut hops = Vec::new();
let mut current = url;
for _ in 0..5 {
let send = client
.get(current.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(20), send)
.await
.map_err(|_| "TimeoutException".to_string())?
.map_err(|_| "ClientException".to_string())?;
let status = response.status().as_u16();
hops.push(json!({
"host": current.host_str().unwrap_or(""),
"status": status,
"headersMs": started.elapsed().as_millis() as u64,
}));
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let (true, Some(location)) = ([301, 302, 303, 307, 308].contains(&status), location) {
drop(response);
current = current
.join(&location)
.map_err(|_| "FormatException".to_string())?;
continue;
}
return Ok((Some(response), hops, current));
}
Ok((None, hops, current))
}
fn status_error(response: Option<&reqwest::Response>) -> String {
match response {
Some(r) => format!("status {}", r.status().as_u16()),
None => "status null".to_string(),
}
}
async fn probe_stream(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, _) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let mut response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let mut bytes = 0usize;
let mut first_byte_ms: Option<u64> = None;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
let read = async {
while let Ok(Some(chunk)) = response.chunk().await {
first_byte_ms.get_or_insert(started.elapsed().as_millis() as u64);
bytes += chunk.len();
if bytes > 1_000_000 {
break;
}
}
};
let _ = tokio::time::timeout(Duration::from_secs(15), read).await;
json!({
"hops": hops,
"firstByteMs": first_byte_ms,
"firstMegabyteMs": (bytes > 1_000_000).then(|| started.elapsed().as_millis() as u64),
})
}
async fn probe_hls(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, final_url) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let body = match response.text().await {
Ok(b) => b,
Err(_) => {
return json!({"error": "ClientException", "afterMs": started.elapsed().as_millis() as u64});
}
};
let playlist_ms = started.elapsed().as_millis() as u64;
let lines: Vec<&str> = body.lines().collect();
let segments: Vec<&str> = lines
.iter()
.copied()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let target = lines
.iter()
.find(|l| l.starts_with("#EXT-X-TARGETDURATION"))
.map(|l| l.rsplit(':').next().unwrap_or("").to_string())
.unwrap_or_default();
if segments.is_empty() {
return json!({"hops": hops, "playlistMs": playlist_ms, "segments": 0});
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
let pick = if segments.len() >= 2 {
segments[segments.len() - 2]
} else {
segments[segments.len() - 1]
};
let segment = match final_url.join(pick) {
Ok(url) => Box::pin(probe_stream(client, url)).await,
Err(_) => json!({"error": "FormatException"}),
};
json!({
"hops": hops,
"playlistMs": playlist_ms,
"segments": segments.len(),
"targetDuration": target,
"segment": segment,
"segmentStartedAtMs": playlist_ms,
})
}
+774
View File
@@ -0,0 +1,774 @@
//! API EPG — `GET /api/epg/<channel_id>`, cache 30 minutes.
//!
//! Le panneau de l'abonné reste la source de référence, mais par son dump
//! `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
//! `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
//! ensuite la source XMLTV externe (déjà indexée en mémoire), puis
//! l'interrogation chaîne par chaîne — cas des revendeurs dont le guide est
//! figé depuis plusieurs jours.
use crate::dart_time::{DartDateTime, utc_iso};
use crate::http::{self, Resp};
use crate::models::PlaylistConfig;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::util::b64;
use crate::util::log_redactor::redact_url;
use chrono::{TimeZone, Utc};
use futures::FutureExt;
use futures::future::{BoxFuture, Shared};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Send + Sync>;
/// Correspondance `stream_id` → identifiant EPG et nom, par compte. Le dump
/// XMLTV indexe par identifiant de chaîne, pas par `stream_id` du panneau.
#[derive(Default)]
struct ChannelMap {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
expires_at: Option<Instant>,
}
struct Inner {
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
/// « dns|username|channelId » → réponse JSON. La clé inclut le compte :
/// deux panneaux peuvent partager un même `stream_id`.
cache: Mutex<HashMap<String, (String, Instant)>>,
channel_maps: Mutex<HashMap<String, Arc<ChannelMap>>>,
channel_maps_in_flight: Mutex<HashMap<String, Shared<BoxFuture<'static, Arc<ChannelMap>>>>>,
panel_xmltv: Mutex<HashMap<String, Arc<XmltvEpgService>>>,
}
#[derive(Clone)]
pub struct EpgApi {
inner: Arc<Inner>,
}
/// Dump XMLTV servi par le panneau lui-même.
///
/// POURQUOI : `get_simple_data_table` répond en plusieurs secondes chez
/// beaucoup de revendeurs, et il faut un appel par chaîne — une grille de
/// trente chaînes demandait plus d'une minute. `xmltv.php` renvoie le guide
/// de toutes les chaînes en une requête, réutilisée pendant des heures.
pub fn default_panel_xmltv(client: reqwest::Client) -> PanelXmltvBuilder {
Arc::new(move |playlist: &PlaylistConfig| {
let enc = |s: &str| url::form_urlencoded::byte_serialize(s.as_bytes()).collect::<String>();
let mut config = XmltvConfig::new(vec![format!(
"{}/xmltv.php?username={}&password={}",
playlist.dns,
enc(&playlist.username),
enc(&playlist.password)
)]);
config.refresh_interval = Duration::from_secs(3 * 3600);
// Guide facultatif : pas plusieurs minutes d'attente sur un panneau lent.
config.download_timeout = Duration::from_secs(90);
XmltvEpgService::new(config, client.clone())
})
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`.
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
let mut epg_ids = HashMap::new();
let mut names = HashMap::new();
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
let text = String::from_utf8_lossy(body);
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&text) {
for item in items {
let Value::Object(item) = item else { continue };
let Some(stream_id) = item.get("stream_id").and_then(value_to_string) else {
continue;
};
if stream_id.is_empty() {
continue;
}
if let Some(epg_id) = item.get("epg_channel_id").and_then(value_to_string)
&& !epg_id.is_empty()
{
epg_ids.insert(stream_id.clone(), epg_id);
}
if let Some(name) = item.get("name").and_then(value_to_string)
&& !name.is_empty()
{
names.insert(stream_id, name);
}
}
}
(epg_ids, names)
}
/// Epoch (secondes, texte ou entier) → ISO-8601 UTC.
fn iso_utc(timestamp: Option<&Value>) -> Option<String> {
let seconds: i64 = match timestamp? {
Value::Number(n) => n.as_i64()?,
Value::String(s) => s.parse().ok()?,
_ => return None,
};
if seconds <= 0 {
return None;
}
Utc.timestamp_opt(seconds, 0).single().map(utc_iso)
}
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
fn normalize_date(raw: &str) -> String {
if raw.contains(' ') && !raw.contains('T') {
raw.replacen(' ', "T", 1)
} else {
raw.to_string()
}
}
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi.
pub fn transform_epg_data(raw: &Value, channel_id: &str) -> Value {
let listings: Vec<Value> = match raw {
Value::Object(map) if map.contains_key("epg_listings") => map
.get("epg_listings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Value::Array(list) => list.clone(),
_ => Vec::new(),
};
let str_of = |item: &Map<String, Value>, key: &str| -> String {
item.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let mut programmes = Vec::new();
for item in listings {
let Value::Object(item) = item else {
return json!({"channel_id": channel_id, "programmes": [], "error": "EPG indisponible"});
};
// Les champs texte `start`/`end` sont dans le fuseau du panneau, sans
// indicateur de zone : le client les relisait en heure locale et
// décalait tout le guide. Les `*_timestamp` sont de l'epoch UTC.
let start = iso_utc(item.get("start_timestamp"))
.unwrap_or_else(|| normalize_date(&str_of(&item, "start")));
let end = iso_utc(item.get("stop_timestamp"))
.or_else(|| iso_utc(item.get("end_timestamp")))
.unwrap_or_else(|| {
let raw = item
.get("stop")
.and_then(Value::as_str)
.or_else(|| item.get("end").and_then(Value::as_str))
.unwrap_or("");
normalize_date(raw)
});
let decode = |raw: String| -> String {
if raw.is_empty() {
return raw;
}
match b64::decode_text(&raw) {
Some(decoded) if !decoded.is_empty() => decoded,
_ => raw,
}
};
programmes.push(json!({
"title": decode(str_of(&item, "title")),
"description": decode(str_of(&item, "description")),
"start": start,
"end": end,
"channel_id": channel_id,
}));
}
json!({"channel_id": channel_id, "programmes": programmes})
}
/// Le guide contient-il un programme en cours ou à venir ? Un panneau au
/// guide figé répond avec des centaines de programmes tous terminés :
/// compter les entrées ne suffit pas.
fn has_current_programme(programmes: &[Value]) -> bool {
let now = Utc::now();
programmes.iter().any(|p| {
p.get("end")
.and_then(Value::as_str)
.and_then(DartDateTime::parse)
.is_some_and(|end| end.to_utc() > now)
})
}
impl EpgApi {
pub fn new(
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
) -> Self {
Self {
inner: Arc::new(Inner {
xmltv,
http,
panel_builder,
cache: Mutex::default(),
channel_maps: Mutex::default(),
channel_maps_in_flight: Mutex::default(),
panel_xmltv: Mutex::default(),
}),
}
}
pub async fn handle(&self, playlist: Option<PlaylistConfig>, channel_id: &str) -> Resp {
let Some(playlist) = playlist else {
return http::json(403, &json!({"error": "Playlist non trouvée"}));
};
let cache_key = format!("{}|{}|{channel_id}", playlist.dns, playlist.username);
if let Some((data, expires)) = self.inner.cache.lock().unwrap().get(&cache_key)
&& Instant::now() < *expires
{
return http::with_headers(
http::response(200, data.clone()),
&[("content-type", "application/json"), ("x-cache", "HIT")],
);
}
// Sources par ordre de préférence. On s'arrête à la première qui
// contient un programme en cours ou à venir ; à défaut, la première
// non vide sert de repli.
let mut programmes: Vec<Value> = Vec::new();
let mut source = "xtream";
let mut found = false;
for name in ["panel-xmltv", "xmltv", "xtream"] {
let candidate = match name {
"panel-xmltv" => self.panel_programmes(&playlist, channel_id).await,
"xmltv" => {
self.programmes_from_xmltv(self.inner.xmltv.clone(), &playlist, channel_id)
.await
}
_ => self.xtream_programmes(&playlist, channel_id).await,
};
if candidate.is_empty() {
continue;
}
let current = has_current_programme(&candidate);
if !found || current {
programmes = candidate;
source = name;
found = true;
}
if current {
break;
}
}
let body = json!({"channel_id": channel_id, "programmes": programmes}).to_string();
// 30 min pour un guide trouvé ; 5 min pour un guide vide, qui vient
// souvent d'une source momentanément indisponible.
let ttl = Duration::from_secs(if found { 30 * 60 } else { 5 * 60 });
self.inner
.cache
.lock()
.unwrap()
.insert(cache_key, (body.clone(), Instant::now() + ttl));
http::with_headers(
http::response(200, body),
&[
("content-type", "application/json"),
("x-cache", "MISS"),
("x-epg-source", source),
],
)
}
async fn panel_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
let key = format!("{}|{}", playlist.dns, playlist.username);
let service = self
.inner
.panel_xmltv
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| Arc::new((self.inner.panel_builder)(playlist)))
.clone();
self.programmes_from_xmltv(Some(service), playlist, channel_id)
.await
}
/// Programmes d'une chaîne dans un index XMLTV (panneau ou externe).
async fn programmes_from_xmltv(
&self,
xmltv: Option<Arc<XmltvEpgService>>,
playlist: &PlaylistConfig,
channel_id: &str,
) -> Vec<Value> {
let Some(xmltv) = xmltv else {
return Vec::new();
};
let channels = self.channel_map_for(playlist).await;
let epg_id = channels.epg_ids.get(channel_id).map(String::as_str);
let name = channels.names.get(channel_id).map(String::as_str);
if epg_id.is_none() && name.is_none() {
return Vec::new();
}
let now = Utc::now();
xmltv
.programmes_for(epg_id, name)
.await
.iter()
.filter(|p| p.stop > now)
.take(64)
.map(|p| p.to_json(channel_id))
.collect()
}
/// Interrogation du panneau chaîne par chaîne : chemin lent, plusieurs
/// secondes par action, réservé aux chaînes absentes des dumps.
async fn xtream_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
// `get_simple_data_table` d'abord : seule action réellement
// universelle. `get_epg` manque sur beaucoup de panneaux, qui
// renvoient alors le payload d'authentification.
for action in ["get_simple_data_table", "get_short_epg"] {
let url = format!(
"{}/player_api.php?username={}&password={}&action={action}&stream_id={channel_id}",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = self.inner.http.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (status, body) = match tokio::time::timeout(Duration::from_secs(20), fetch).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
// Une source parmi d'autres, pas une raison de répondre 500.
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : {}",
redact_url(&format!("{e:?}"))
);
continue;
}
Err(_) => {
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : TimeoutException after 0:00:20.000000"
);
continue;
}
};
if status != 200 {
continue;
}
let Ok(raw) = serde_json::from_str::<Value>(&String::from_utf8_lossy(&body)) else {
continue;
};
let programmes: Vec<Value> = transform_epg_data(&raw, channel_id)["programmes"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(Value::is_object)
.collect();
if !programmes.is_empty() {
return programmes;
}
}
Vec::new()
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures. Les
/// requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup (7 Mo de table en prod).
async fn channel_map_for(&self, playlist: &PlaylistConfig) -> Arc<ChannelMap> {
let key = format!("{}|{}", playlist.dns, playlist.username);
if let Some(map) = self.inner.channel_maps.lock().unwrap().get(&key)
&& map.expires_at.is_some_and(|e| Instant::now() < e)
{
return map.clone();
}
let shared = {
let mut in_flight = self.inner.channel_maps_in_flight.lock().unwrap();
match in_flight.get(&key) {
Some(f) => f.clone(),
None => {
let inner = self.inner.clone();
let playlist = playlist.clone();
let task_key = key.clone();
// Tâche détachée : le téléchargement aboutit même si le
// client qui l'a déclenché est parti.
let handle = tokio::spawn(async move {
let map = Arc::new(load_channel_map(&inner.http, &playlist).await);
inner
.channel_maps
.lock()
.unwrap()
.insert(task_key.clone(), map.clone());
inner
.channel_maps_in_flight
.lock()
.unwrap()
.remove(&task_key);
map
});
let f = async move { handle.await.unwrap_or_default() }
.boxed()
.shared();
in_flight.insert(key, f.clone());
f
}
}
};
shared.await
}
}
async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -> ChannelMap {
let url = format!(
"{}/player_api.php?username={}&password={}&action=get_live_streams",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = client.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new());
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
Ok(Ok((200, body))) => {
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
// des flux en cours.
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
Ok(parsed) => (epg_ids, names) = parsed,
Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"),
}
}
Ok(Ok(_)) => {}
Ok(Err(e)) => println!(
"[EpgApi] table des chaînes indisponible : {}",
redact_url(&format!("{e:?}"))
),
Err(_) => println!(
"[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000"
),
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
let ttl = if names.is_empty() {
Duration::from_secs(120)
} else {
Duration::from_secs(6 * 3600)
};
ChannelMap {
epg_ids,
names,
expires_at: Some(Instant::now() + ttl),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{Query, Request};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn convertit_les_epoch_en_utc_et_decode_le_base64() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"end": "2026-08-11 20:35:00",
"description": "TCdpbmZvIGR1IHNvaXI=",
"start_timestamp": "1786471200",
"stop_timestamp": "1786473300",
}]});
let result = transform_epg_data(&raw, "845452");
let p = &result["programmes"][0];
assert_eq!(p["start"], "2026-08-11T18:00:00.000Z");
assert_eq!(p["end"], "2026-08-11T18:35:00.000Z");
assert_eq!(p["title"], "Journal de 20h");
assert_eq!(p["description"], "L'info du soir");
assert_eq!(p["channel_id"], "845452");
}
#[test]
fn retombe_sur_les_champs_texte() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"stop": "2026-08-11 20:35:00",
}]});
let p = &transform_epg_data(&raw, "845452")["programmes"][0];
assert_eq!(p["start"], "2026-08-11T20:00:00");
assert_eq!(p["end"], "2026-08-11T20:35:00");
}
#[test]
fn liste_vide_sans_epg_listings() {
let raw = json!({"user_info": {"auth": 1}, "server_info": {"url": "x"}});
assert_eq!(transform_epg_data(&raw, "1")["programmes"], json!([]));
}
// ---------- handle() contre un faux panneau ----------
type Handler = Arc<dyn Fn(&str, &HashMap<String, String>) -> (u16, String) + Send + Sync>;
async fn mock(handler: Handler) -> String {
let app = axum::Router::new().fallback(
move |Query(q): Query<HashMap<String, String>>, req: Request| {
let handler = handler.clone();
async move {
// Laisser respirer les requêtes concurrentes (mutualisation).
tokio::time::sleep(Duration::from_millis(30)).await;
let (status, body) = handler(req.uri().path(), &q);
(axum::http::StatusCode::from_u16(status).unwrap(), body)
}
},
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
fn stamp(d: chrono::DateTime<Utc>) -> String {
d.format("%Y%m%d%H%M%S +0000").to_string()
}
fn dump(
id: &str,
name: &str,
start: chrono::DateTime<Utc>,
stop: chrono::DateTime<Utc>,
) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?><tv><channel id="{id}"><display-name>{name}</display-name></channel><programme start="{}" stop="{}" channel="{id}"><title lang="fr">Journal de 20h</title><desc lang="fr">L'info du soir</desc></programme></tv>"#,
stamp(start),
stamp(stop)
)
}
fn playlist(base: &str) -> PlaylistConfig {
PlaylistConfig {
dns: base.to_string(),
username: "u".into(),
password: "p".into(),
}
}
fn api(base: &str, external: bool) -> EpgApi {
let client = reqwest::Client::new();
let xmltv = external.then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(vec![format!("{base}/ext/epg.xml")]),
client.clone(),
))
});
let c = client.clone();
let builder: PanelXmltvBuilder = Arc::new(move |p: &PlaylistConfig| {
XmltvEpgService::new(
XmltvConfig::new(vec![format!("{}/xmltv.php", p.dns)]),
c.clone(),
)
});
EpgApi::new(xmltv, client, builder)
}
async fn body_json(resp: Resp) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn sert_le_dump_du_panneau_sans_appel_par_chaine() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, dump("France2.fr", "FRANCE 2", now - chrono::Duration::minutes(5), now + chrono::Duration::minutes(25)));
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (200, json!([{"stream_id": 845452, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}]).to_string());
}
(200, "{}".into())
}))
.await;
let resp = api(&base, false)
.handle(Some(playlist(&base)), "845452")
.await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
let body = body_json(resp).await;
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]);
}
#[tokio::test]
async fn retombe_sur_player_api_quand_le_dump_ne_couvre_pas_la_chaine() {
let future = chrono::Local::now() + chrono::Duration::minutes(10);
let panel_stamp =
|d: chrono::DateTime<chrono::Local>| d.format("%Y-%m-%d %H:%M:%S").to_string();
let start = panel_stamp(future);
let end = panel_stamp(future + chrono::Duration::hours(2));
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
match q.get("action").map(String::as_str) {
Some("get_live_streams") => (200, "[]".into()),
Some("get_simple_data_table") => (
200,
json!({"epg_listings": [{"title": "TWF0Y2g=", "description": "", "start": start, "end": end}]}).to_string(),
),
_ => (200, "{}".into()),
}
}))
.await;
let resp = api(&base, false).handle(Some(playlist(&base)), "999").await;
assert_eq!(resp.headers()["x-epg-source"], "xtream");
assert_eq!(body_json(resp).await["programmes"][0]["title"], "Match");
}
#[tokio::test]
async fn un_panneau_qui_plante_ne_prive_pas_du_dump_externe() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 1, "name": "FR - TF1 FHD ◉", "epg_channel_id": ""}])
.to_string(),
);
}
(500, "boom".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert_eq!(
body_json(resp).await["programmes"][0]["title"],
"Journal de 20h"
);
}
#[tokio::test]
async fn le_dump_externe_passe_avant_l_appel_lent() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (
200,
json!([{"stream_id": 1, "name": "TF1", "epg_channel_id": "TF1.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert!(
!actions
.lock()
.unwrap()
.contains(&"get_simple_data_table".to_string())
);
}
#[tokio::test]
async fn la_grille_partage_un_seul_telechargement_de_la_table() {
let now = Utc::now();
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
c.fetch_add(1, Ordering::SeqCst);
let list: Vec<Value> = (1..=5)
.map(|i| json!({"stream_id": i, "name": "TF1", "epg_channel_id": "TF1.fr"}))
.collect();
return (200, Value::Array(list).to_string());
}
(200, "{}".into())
}))
.await;
let api = api(&base, true);
let ids: Vec<String> = (1..=5).map(|i| i.to_string()).collect();
let responses =
futures::future::join_all(ids.iter().map(|id| api.handle(Some(playlist(&base)), id)))
.await;
assert!(responses.iter().all(|r| r.status().as_u16() == 200));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cache_hit_au_second_appel() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (
200,
dump(
"France2.fr",
"FRANCE 2",
now,
now + chrono::Duration::minutes(25),
),
);
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 7, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let api = api(&base, false);
let first = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(first.headers()["x-cache"], "MISS");
let second = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(second.headers()["x-cache"], "HIT");
assert!(api.handle(None, "7").await.status().as_u16() == 403);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! `GET /api/logo?src=<stream_icon>&name=<nom de chaîne>`
//!
//! Logo d'une chaîne, avec repli. L'URL du panneau est tentée d'abord, via
//! le proxy Xtream (mêmes protections anti-SSRF, même mémoire des hôtes
//! morts). En cas d'échec, le logo est cherché par nom dans le dépôt public
//! `tv-logos`.
//!
//! POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
//! perd ses logos d'un coup, alors que les autres applications IPTV les
//! affichent encore grâce à leurs propres sources de repli.
use crate::api::proxy::missing_image_response;
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::http::{HeaderMap, Method};
/// Un logo trouvé change rarement : un jour de cache navigateur.
const FOUND: &str = "public, max-age=86400";
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let params = req.query_params();
let src = params.get("src").cloned().unwrap_or_default();
let name = params.get("name").cloned().unwrap_or_default();
if src.starts_with("http://") || src.starts_with("https://") {
let (path, query) = match src.split_once('?') {
Some((p, q)) => (p, q),
None => (src.as_str(), ""),
};
let path = path.split('#').next().unwrap_or("");
let query = query.split('#').next().unwrap_or("");
// Requête interne vers le proxy, avec la seule session du client.
let mut headers = HeaderMap::new();
for header in ["cookie", "authorization"] {
if let Some(value) = req.headers.get(header) {
headers.insert(header, value.clone());
}
}
let proxied = Req {
method: Method::GET,
path: format!("api/xtream/{path}"),
query: query.to_string(),
headers,
body: Default::default(),
remote: req.remote,
user: None,
user_id: None,
};
let upstream = state.proxy.handle(state, &proxied).await;
let content_type = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if upstream.status().as_u16() == 200 && content_type.starts_with("image/") {
return http::with_headers(
http::response(200, upstream.into_body()),
&[("content-type", &content_type), ("cache-control", FOUND)],
);
}
// Réponse abandonnée : la connexion amont est refermée.
drop(upstream);
}
if !name.is_empty()
&& let Some(bytes) = state.logo_catalog.logo_for(&name).await
{
return http::with_headers(
http::response(200, bytes),
&[("content-type", "image/png"), ("cache-control", FOUND)],
);
}
missing_image_response()
}
+11
View File
@@ -0,0 +1,11 @@
pub mod account;
pub mod admin;
pub mod epg;
pub mod logo;
pub mod player_log;
pub mod proxy;
pub mod recording_playlist;
pub mod recordings;
pub mod static_files;
pub mod streaming;
pub mod xtream_api;
+135
View File
@@ -0,0 +1,135 @@
//! Journal des incidents de lecture remontés par le lecteur web.
//!
//! POURQUOI : les freezes se produisent chez l'utilisateur, dans son
//! navigateur ; le serveur n'en voit rien. Le lecteur envoie ici chaque
//! coupure, erreur, recréation et saut, et le serveur les écrit dans
//! `docker logs` (`[Player] …`).
//!
//! Entrée non fiable : seuls des champs connus, courts et typés passent.
use crate::http::{self, Req, Resp};
use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
const EVENTS: [&str; 11] = [
"start",
"first_frame",
"stall",
"seek",
"mpegts_error",
"recreate",
"fallback_hls",
"hls_error",
"profile",
"error_shown",
"heartbeat",
];
const NUMERIC_FIELDS: [&str; 11] = [
"t", "ms", "buffer", "pos", "from", "to", "stalls", "stallMs", "seeks", "errors", "rate",
];
const TEXT_FIELDS: [&str; 5] = ["session", "ch", "mode", "profile", "detail"];
static URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://\S+").unwrap());
static UNSAFE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9_.\-:/<>]").unwrap());
/// Ligne de journal pour l'événement [raw], ou `None` s'il est invalide.
pub fn format_player_event(raw: &Value) -> Option<String> {
let map = raw.as_object()?;
let event = map.get("event")?.as_str()?;
if !EVENTS.contains(&event) {
return None;
}
let mut parts = vec![event.to_string()];
for key in TEXT_FIELDS {
if let Some(value) = map.get(key).and_then(Value::as_str) {
if value.is_empty() {
continue;
}
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
// journaux, jamais d'identifiants recopiés depuis une URL.
let safe = URL.replace_all(value, "<url>");
let safe = UNSAFE.replace_all(&safe, "_");
let safe: String = safe.chars().take(60).collect();
parts.push(format!("{key}={safe}"));
}
}
for key in NUMERIC_FIELDS {
let Some(Value::Number(n)) = map.get(key) else {
continue;
};
if let Some(i) = n.as_i64() {
parts.push(format!("{key}={i}"));
} else if let Some(f) = n.as_f64().filter(|f| f.is_finite()) {
parts.push(format!("{key}={f:.1}"));
}
}
Some(format!("[Player] {}", parts.join(" ")))
}
pub fn handle(req: &Req) -> Resp {
let body = req.body_str();
if body.encode_utf16().count() > 4096 {
return http::empty(413);
}
let Ok(decoded) = serde_json::from_str::<Value>(&body) else {
return http::text(400, "Bad Request");
};
let events: Vec<&Value> = match &decoded {
Value::Array(list) => list.iter().take(20).collect(),
other => vec![other],
};
for event in events {
if let Some(line) = format_player_event(event) {
println!("{line}");
}
}
http::empty(204)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ecrit_une_coupure_avec_sa_duree_et_le_buffer() {
assert_eq!(
format_player_event(&json!({
"event": "stall", "session": "ab12", "ch": "554034", "ms": 5230, "buffer": 0.04
}))
.unwrap(),
"[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0"
);
}
#[test]
fn refuse_un_evenement_inconnu() {
assert!(format_player_event(&json!({"event": "rm -rf"})).is_none());
assert!(format_player_event(&json!("stall")).is_none());
assert!(format_player_event(&Value::Null).is_none());
}
#[test]
fn pas_d_injection_ni_d_url() {
let line = format_player_event(&json!({
"event": "mpegts_error",
"detail": "NetworkError http://panel/live/user/pass/1.ts\n[Player] faux"
}))
.unwrap();
assert!(!line.contains('\n'));
assert!(!line.contains("user/pass"));
assert!(line.contains("<url>"));
}
#[test]
fn ignore_les_champs_inattendus() {
let line =
format_player_event(&json!({"event": "seek", "from": 10, "cookie": "secret"})).unwrap();
assert!(line.contains("from=10"));
assert!(!line.contains("to="));
assert!(!line.contains("secret"));
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Proxy Xtream `/api/xtream/<url>` (images, API du panneau) avec
//! protections anti-SSRF, et mémoire des hôtes d'images hors service.
use crate::http::{self, Req, Resp, extract_token};
use crate::models::PlaylistConfig;
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Réponse pour une image indisponible.
///
/// Statut 410 et non 404 : la cascade du serveur retombe sur l'étage suivant
/// pour tout 404 et renverrait finalement celui des fichiers statiques, sans
/// notre `cache-control`. Un 410 s'arrête ici.
///
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
/// est essentiel : sans lui le navigateur redemande le logo à chaque
/// affichage de la grille.
pub fn missing_image_response() -> Resp {
http::with_headers(
http::empty(410),
&[
("cache-control", "public, max-age=600"),
("access-control-allow-origin", "*"),
],
)
}
/// Mémoire courte des hôtes d'images injoignables.
///
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
/// sur le panneau. Quand cette machine tombe, elle répond 5xx en quelques
/// millisecondes, le navigateur réessaie à chaque rendu de la grille et le
/// proxy relaie des centaines de requêtes mortes par seconde. Au bout de
/// [threshold] échecs consécutifs, l'hôte est tenu pour mort pendant [ttl]
/// et les requêtes suivantes sont servies localement, sans appel sortant.
pub struct AssetFailureCache {
threshold: u32,
ttl: Duration,
failures: Mutex<HashMap<String, u32>>,
down_until: Mutex<HashMap<String, Instant>>,
}
impl AssetFailureCache {
pub fn new(threshold: u32, ttl: Duration) -> Self {
Self {
threshold,
ttl,
failures: Mutex::default(),
down_until: Mutex::default(),
}
}
pub fn is_down(&self, host: &str) -> bool {
self.is_down_at(host, Instant::now())
}
fn is_down_at(&self, host: &str, now: Instant) -> bool {
let key = host.to_lowercase();
let mut down = self.down_until.lock().unwrap();
let Some(until) = down.get(&key).copied() else {
return false;
};
if now < until {
return true;
}
// Coupure expirée : ardoise vierge pour laisser une vraie chance.
down.remove(&key);
self.failures.lock().unwrap().remove(&key);
false
}
pub fn record_failure(&self, host: &str) {
self.record_failure_at(host, Instant::now());
}
fn record_failure_at(&self, host: &str, now: Instant) {
let key = host.to_lowercase();
let count = {
let mut failures = self.failures.lock().unwrap();
let count = failures.entry(key.clone()).or_insert(0);
*count += 1;
*count
};
if count >= self.threshold {
self.down_until.lock().unwrap().insert(key, now + self.ttl);
}
}
pub fn record_success(&self, host: &str) {
let key = host.to_lowercase();
self.failures.lock().unwrap().remove(&key);
self.down_until.lock().unwrap().remove(&key);
}
}
/// L'URL désigne-t-elle une image ou un logo ? Ces URL échappent à
/// l'allowlist de domaine (les revendeurs hébergent leurs picons ailleurs
/// que le panneau) et méritent un repli visuel plutôt qu'une erreur.
fn is_static_asset_url(url: &reqwest::Url) -> bool {
let path = url.path();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/picons/")
|| path.contains("/logos/")
}
/// Hôte à ne jamais atteindre par le proxy (loopback, LAN privé,
/// link-local/métadonnées cloud) — protection SSRF des URL d'images qui
/// échappent à l'allowlist de domaine.
pub fn is_forbidden_proxy_host(host: &str) -> bool {
let lower = host.to_lowercase();
let lower = lower.trim_start_matches('[').trim_end_matches(']');
if lower == "localhost" || lower == "::1" {
return true;
}
let Ok(ip) = lower.parse::<IpAddr>() else {
return false; // nom d'hôte : validé par l'allowlist de domaine
};
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o[0] == 0
}
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn host_of(url: &reqwest::Url) -> String {
url.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']')
.to_string()
}
const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
"content-type",
"content-range",
"accept-ranges",
"cache-control",
"server",
"date",
];
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
pub struct ProxyHandler {
asset_failures: AssetFailureCache,
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
/// une requête SQL par segment.
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
}
enum ProxyError {
Response(Resp),
Failure(String),
}
impl ProxyHandler {
pub fn new() -> Self {
Self {
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
playlist_cache: Mutex::new(None),
}
}
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
&& Instant::now() < *expiry
{
return Some(playlist.clone());
}
let playlist = state.get_playlist(req)?;
*self.playlist_cache.lock().unwrap() =
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
Some(playlist)
}
pub async fn handle(&self, state: &AppState, req: &Req) -> Resp {
// Le test de chemin passe AVANT tout : les autres requêtes doivent
// retomber sur les étages suivants de la cascade.
let Some(api_path) = req.path.strip_prefix("api/xtream/") else {
return http::empty(404);
};
// Authentification par session (en-tête ou cookie HttpOnly posé au
// login : `img src` et hls.js n'envoient que le cookie). Un proxy
// ouvert offrait un rebond SSRF non authentifié.
let authorized = extract_token(req)
.is_some_and(|token| state.db.find_session_by_token(&token).is_some());
if !authorized {
return http::text(401, "Unauthorized");
}
let mut api_path = api_path.to_string();
if api_path.starts_with("http%3A") || api_path.starts_with("https%3A") {
api_path = percent_encoding::percent_decode_str(&api_path)
.decode_utf8_lossy()
.into_owned();
}
if !api_path.starts_with("http://") && !api_path.starts_with("https://") {
return http::text(
400,
"Invalid API URL. Expected format: /api/xtream/http://...",
);
}
let mut full_url = api_path;
if !req.query.is_empty() {
full_url.push(if full_url.contains('?') { '&' } else { '?' });
full_url.push_str(&req.query);
}
let target = reqwest::Url::parse(&full_url).ok();
match self.forward(state, req, target.clone()).await {
Ok(resp) => resp,
Err(ProxyError::Response(resp)) => resp,
Err(ProxyError::Failure(error)) => {
// Une erreur reqwest recopie l'URL amont, identifiants inclus :
// jamais de détail vers le client, log masqué.
println!(
"[ProxyHandler] error on {}: {}",
redact_url(&req.path),
redact_url(&error)
);
if let Some(target) = target.filter(is_static_asset_url) {
self.asset_failures.record_failure(&host_of(&target));
return missing_image_response();
}
http::json(500, &json!({"error": "Proxy error"}))
}
}
}
async fn forward(
&self,
state: &AppState,
req: &Req,
target: Option<reqwest::Url>,
) -> Result<Resp, ProxyError> {
let target =
target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?;
if target.scheme() != "http" && target.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported URL scheme",
)));
}
let target_host = host_of(&target);
if is_forbidden_proxy_host(&target_host) {
println!("[Proxy] Blocked SSRF attempt to private host: {target_host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
// Images : tout hôte autorisé (CDN de picons des revendeurs).
let is_static_asset = is_static_asset_url(&target);
// Hôte d'images déjà constaté mort : réponse immédiate, sans appel
// sortant ni log — c'est précisément le flot qu'on veut éteindre.
if is_static_asset && self.asset_failures.is_down(&target_host) {
return Ok(missing_image_response());
}
let mut allowed_host = None;
if !is_static_asset {
// Appels d'API : allowlist de domaine.
let Some(playlist) = self.cached_playlist(state, req) else {
return Err(ProxyError::Response(http::text(
403,
"No active playlist configuration found to validate request",
)));
};
let allowed = reqwest::Url::parse(&playlist.dns)
.map(|u| host_of(&u).to_lowercase())
.unwrap_or_default();
if target_host.to_lowercase() != allowed {
println!(
"[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})",
target_host.to_lowercase()
);
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
allowed_host = Some(allowed);
}
let mut headers = reqwest::header::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
headers.insert(n, v);
}
};
set("user-agent", "VLC/3.0.18 LibVLC/3.0.18");
set("accept", "*/*");
set("accept-encoding", "identity");
for name in ALLOWED_REQUEST_HEADERS {
if let Some(value) = req.header(name) {
set(name, value);
}
}
println!("[Proxy] Forwarding to: {}", redact_url(target.as_str()));
let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone());
// Redirections suivies À LA MAIN : chaque destination est revalidée
// (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à
// atteindre un hôte interne malgré l'anti-SSRF.
let mut current = target.clone();
let mut redirects = 0;
let response = loop {
let mut builder = state
.http_manual
.request(method.clone(), current.clone())
.headers(headers.clone());
if let Some(body) = &post_body {
builder = builder.body(body.clone());
}
// 90 s : laisse le front (60 s) abandonner proprement le premier.
let response = tokio::time::timeout(Duration::from_secs(90), builder.send())
.await
.map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))?
.map_err(|e| ProxyError::Failure(format!("{e:?}")))?;
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location.filter(|_| (300..400).contains(&status)) else {
break response;
};
redirects += 1;
if redirects > 3 {
return Err(ProxyError::Response(http::text(403, "Too many redirects")));
}
current = current
.join(&location)
.map_err(|e| ProxyError::Failure(e.to_string()))?;
if current.scheme() != "http" && current.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported redirect scheme",
)));
}
let host = host_of(&current);
if is_forbidden_proxy_host(&host) {
println!("[Proxy] Blocked SSRF redirect to private host: {host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
if let Some(allowed) = &allowed_host
&& host.to_lowercase() != *allowed
{
println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})");
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
};
// Une image en erreur n'a rien à transmettre au navigateur : relayer
// le 503 déclenchait un nouveau cycle de requêtes à chaque rendu.
// L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus).
let status = response.status().as_u16();
if is_static_asset {
if status >= 400 {
self.asset_failures.record_failure(&target_host);
return Ok(missing_image_response());
}
self.asset_failures.record_success(&target_host);
}
let upstream_headers = response.headers().clone();
let mut resp = http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
);
http::set_header(&mut resp, "access-control-allow-origin", "*");
for name in ALLOWED_RESPONSE_HEADERS {
let values: Vec<&str> = upstream_headers
.get_all(name)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
if !values.is_empty() {
http::set_header(&mut resp, name, &values.join(", "));
}
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloque_le_loopback() {
assert!(is_forbidden_proxy_host("127.0.0.1"));
assert!(is_forbidden_proxy_host("localhost"));
assert!(is_forbidden_proxy_host("::1"));
assert!(is_forbidden_proxy_host("[::1]"));
}
#[test]
fn bloque_les_plages_privees() {
assert!(is_forbidden_proxy_host("10.0.0.5"));
assert!(is_forbidden_proxy_host("172.16.0.1"));
assert!(is_forbidden_proxy_host("172.31.255.255"));
assert!(is_forbidden_proxy_host("192.168.1.10"));
assert!(is_forbidden_proxy_host("169.254.169.254"));
assert!(is_forbidden_proxy_host("0.0.0.0"));
}
#[test]
fn autorise_les_ip_publiques_et_les_noms() {
assert!(!is_forbidden_proxy_host("8.8.8.8"));
assert!(!is_forbidden_proxy_host("172.32.0.1"));
assert!(!is_forbidden_proxy_host("cdn.example.com"));
}
#[test]
fn cache_des_hotes_morts() {
let cache = AssetFailureCache::new(3, Duration::from_secs(300));
let t0 = Instant::now();
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
cache.record_success("cdn.example");
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("CDN.example", t0);
cache.record_failure_at("cdn.example", t0);
assert!(cache.is_down_at("cdn.example", t0));
// Coupure expirée : ardoise vierge.
assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301)));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Adressage des sessions de lecture d'un enregistrement.
//!
//! Un enregistrement est transcodé à la volée, séquentiellement. Reprendre à
//! 45 min sans le dire à FFmpeg imposerait d'attendre que l'encodeur y
//! arrive : le lecteur demande donc une playlist qui *commence* à cette
//! position (`?start=2700`), servie par une session FFmpeg dédiée.
use regex::Regex;
use std::sync::LazyLock;
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
pub fn parse_recording_start(raw: Option<&str>) -> u64 {
let Some(value) = raw.and_then(|r| r.trim().parse::<f64>().ok()) else {
return 0;
};
if !value.is_finite() || value <= 0.0 {
return 0;
}
value.floor() as u64
}
/// Segment de chemin qui rattache un `.ts` à SA session : la query string ne
/// survit pas aux URL relatives de la playlist, et les playlists démarrées à
/// 0 s et à 45 min demanderaient sinon toutes deux `segment_000.ts`.
pub fn recording_offset_key(start: u64) -> String {
format!("t{start}")
}
/// Inverse de [recording_offset_key] ; `None` si la clé est mal formée.
pub fn parse_recording_offset_key(key: &str) -> Option<u64> {
let digits = key.strip_prefix('t')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit() || b == b'+') {
return None;
}
digits.parse::<u64>().ok()
}
static SEGMENT_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(segment_\d+\.ts)\s*$").unwrap());
/// Préfixe les lignes de segment de [playlist] par [offset_key] ; balises
/// `#EXT-X-…` et lignes vides passent telles quelles.
pub fn rewrite_recording_playlist(playlist: &str, offset_key: &str) -> String {
SEGMENT_LINE
.replace_all(playlist, |caps: &regex::Captures| {
format!("{offset_key}/{}", &caps[1])
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lit_la_position() {
assert_eq!(parse_recording_start(Some("2700")), 2700);
assert_eq!(parse_recording_start(Some("2700.9")), 2700);
for bad in [
None,
Some(""),
Some("abc"),
Some("-30"),
Some("NaN"),
Some("Infinity"),
] {
assert_eq!(parse_recording_start(bad), 0);
}
}
#[test]
fn cle_d_offset_aller_retour() {
for start in [0, 1, 2700, 86400] {
assert_eq!(
parse_recording_offset_key(&recording_offset_key(start)),
Some(start)
);
}
for bad in ["t", "segment_000.ts", "..", "t-30", "tabc"] {
assert_eq!(parse_recording_offset_key(bad), None, "{bad}");
}
}
const PLAYLIST: &str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXTINF:4.000000,\nsegment_000.ts\n#EXTINF:4.000000,\nsegment_001.ts\n";
#[test]
fn rattache_chaque_segment_a_sa_session() {
let out = rewrite_recording_playlist(PLAYLIST, "t2700");
assert!(out.contains("t2700/segment_000.ts"));
assert!(out.contains("t2700/segment_001.ts"));
assert!(!out.contains("\nsegment_"));
}
#[test]
fn laisse_les_balises() {
let out = rewrite_recording_playlist(PLAYLIST, "t0");
assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:3\n"));
assert!(out.contains("#EXT-X-PLAYLIST-TYPE:EVENT"));
assert_eq!(out.matches("#EXTINF:4.000000,").count(), 2);
}
#[test]
fn idempotent() {
let once = rewrite_recording_playlist(PLAYLIST, "t60");
assert_eq!(rewrite_recording_playlist(&once, "t60"), once);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! `/api/recordings` : liste, programmation, arrêt, suppression, logs.
//! `/api/season-passes` : enregistrements répétés par titre d'émission.
use crate::dart_time::DartDateTime;
use crate::http::{self, Req, Resp, json, segments};
use crate::models::{Recording, User};
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
use crate::state::SharedState;
use crate::util::{media_probe, safe_path};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::LazyLock;
/// L'utilisateur peut-il agir sur cet enregistrement ?
fn can_access(user: Option<&User>, recording: &Recording) -> bool {
user.is_some_and(|u| u.is_admin || recording.user_id == u.id)
}
pub async fn recordings(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list(state, req).await,
("POST", []) => create(state, req),
("DELETE", [id]) => delete(state, req, id),
("POST", ["stop", id]) => stop(state, req, id),
("GET" | "HEAD", ["logs", id]) => logs(state, req, id).await,
_ => http::not_found(),
}
}
async fn logs(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
let Some(file_path) = &recording.file_path else {
return json(
404,
&json!({"error": "Aucun fichier ni log associé pour le moment."}),
);
};
// Enregistrements en .mkv, log .log à côté.
let log_path = Path::new(file_path).with_extension("log");
// Anti traversée : le log doit rester dans le dossier des enregistrements.
let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), &log_path.to_string_lossy())
else {
return json(403, &json!({"error": "Chemin de log invalide"}));
};
match tokio::fs::read(&safe).await {
Ok(bytes) => json(200, &json!({"logs": String::from_utf8_lossy(&bytes)})),
Err(_) => json(404, &json!({"error": "Le fichier de log est introuvable."})),
}
}
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
/// enrichis du suivi : taille, progression, relances, durée réelle.
async fn list(state: &SharedState, req: &Req) -> Resp {
let recordings = match &req.user {
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
_ => state.db.get_all_recordings(),
};
let now = Utc::now();
let mut enriched = Vec::with_capacity(recordings.len());
for r in &recordings {
let mut map = enrich(&state.scheduler, r, now);
// Durée réelle du média : `end_time - start_time` n'est que la durée
// programmée, fausse pour un enregistrement arrêté ou tronqué.
if let Some(duration) = probe_duration(r).await {
map.insert("duration_seconds".into(), json!(duration));
}
enriched.push(Value::Object(map));
}
json(200, &Value::Array(enriched))
}
/// Une capture en cours grossit en permanence : le cache de ffprobe serait
/// invalidé à chaque appel. Seuls les enregistrements terminés sont mesurés.
async fn probe_duration(r: &Recording) -> Option<f64> {
let path = r.file_path.as_deref()?;
if r.status != "completed" {
return None;
}
let safe = safe_path::resolve_within(&recordings_dir_path(), path)?;
media_probe::duration(&safe).await
}
fn enrich(scheduler: &RecordingScheduler, r: &Recording, now: DateTime<Utc>) -> Map<String, Value> {
let mut map = r.to_map();
if r.status == "recording" {
let start = r.start_utc();
let total = (r.end_utc() - start).num_seconds();
if total > 0 {
let elapsed = (now - start).num_seconds();
let pct = (elapsed as f64 * 100.0 / total as f64)
.clamp(0.0, 100.0)
.round() as i64;
map.insert("progress_pct".into(), json!(pct));
}
map.insert("is_active".into(), json!(scheduler.is_capturing(&r.id)));
if let Some(retries) = scheduler.retry_count_of(&r.id) {
map.insert("retry_count".into(), json!(retries));
}
}
if let Some(path) = &r.file_path
&& let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
{
map.insert("file_size_bytes".into(), json!(meta.len()));
}
map
}
static ZONE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+-]\d{2}:?\d{2}$").unwrap());
/// Date ISO-8601 AVEC fuseau, normalisée en UTC. Une date naïve est
/// ambiguë : l'interpréter dans le fuseau du serveur décalait
/// l'enregistrement de plusieurs heures selon le TZ du conteneur.
fn parse_zoned_date(raw: Option<&Value>) -> Option<DateTime<Utc>> {
let s = match raw? {
Value::String(s) => s.clone(),
Value::Null => return None,
other => other.to_string(),
};
if s.is_empty() || !(s.ends_with('Z') || ZONE_SUFFIX.is_match(&s)) {
return None;
}
DartDateTime::parse(&s).map(|d| d.to_utc())
}
fn to_text(v: Option<&Value>) -> Option<String> {
match v? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
fn create(state: &SharedState, req: &Req) -> Resp {
let Some(user_id) = req
.user
.as_ref()
.map(|u| u.id.clone())
.or(req.user_id.clone())
else {
return json(401, &json!({"error": "Authentification requise"}));
};
let Ok(Value::Object(data)) = serde_json::from_slice::<Value>(&req.body) else {
return json(400, &json!({"error": "Corps JSON invalide"}));
};
let channel_id = to_text(data.get("channel_id")).unwrap_or_default();
let stream_url = to_text(data.get("stream_url")).unwrap_or_default();
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let (Some(start), Some(end)) = (
parse_zoned_date(data.get("start_time")),
parse_zoned_date(data.get("end_time")),
) else {
return json(
400,
&json!({"error": "start_time et end_time doivent être des dates ISO-8601 avec fuseau (ex: 2026-08-27T21:00:00Z)"}),
);
};
if end <= start {
return json(
400,
&json!({"error": "end_time doit être après start_time"}),
);
}
let max_hours: i64 = std::env::var("MAX_RECORDING_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12);
if end - start > chrono::Duration::hours(max_hours) {
return json(
400,
&json!({"error": format!("Durée maximale dépassée ({max_hours} h)")}),
);
}
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
match state
.db
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
{
Ok(recording) => json(200, &Value::Object(recording.to_map())),
Err(e) => {
println!("[RecordingsApi] Erreur à la création: {e}");
json(500, &json!({"error": "Erreur lors de la programmation"}))
}
}
}
/// Annule ou supprime un enregistrement : FFmpeg arrêté s'il tourne,
/// fichiers (.mkv, .log, parties) supprimés avec la ligne.
fn delete(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
// Tuer FFmpeg AVANT de supprimer de la base.
state.scheduler.stop_recording(id);
if let Some(path) = &recording.file_path {
// Confiné au dossier des enregistrements.
if let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), path) {
RecordingScheduler::delete_recording_files(&safe);
}
}
state.db.delete_recording(id);
json(
200,
&json!({"message": "Enregistrement supprimé avec succès"}),
)
}
fn stop(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
if state.scheduler.stop_recording(id) {
return json(200, &json!({"message": "Enregistrement arrêté"}));
}
match recording.status.as_str() {
// Rien capturé : annulé, pas « terminé » (la lecture échouerait).
"scheduled" => {
state
.db
.update_recording_status(id, "cancelled", None, None, false);
json(200, &json!({"message": "Enregistrement annulé"}))
}
// « recording » sans processus (orphelin) : clôturer.
"recording" => {
state
.db
.update_recording_status(id, "completed", None, None, false);
json(
200,
&json!({"message": "Enregistrement marqué comme terminé"}),
)
}
// Déjà clôturé : ne pas écraser le statut final.
_ => json(200, &json!({"message": "Enregistrement déjà clôturé"})),
}
}
// ==================== /api/season-passes ====================
pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
let passes = if user.is_admin {
state.db.get_all_season_passes()
} else {
state.db.get_season_passes_for_user(&user.id)
};
json(
200,
&Value::Array(passes.into_iter().map(Value::Object).collect()),
)
}
("POST", []) => create_pass(state, req),
("DELETE", [id]) => {
let Some(pass) = state.db.get_season_pass_by_id(id) else {
return json(404, &json!({"error": "Season Pass non trouvé"}));
};
// Seul le propriétaire ou un admin supprime.
let owner = pass.get("user_id").and_then(Value::as_str);
let allowed = req
.user
.as_ref()
.is_some_and(|u| u.is_admin || owner == Some(u.id.as_str()));
if !allowed {
return json(403, &json!({"error": "Accès refusé"}));
}
state.db.delete_season_pass(id);
json(200, &json!({"message": "Season Pass supprimé"}))
}
_ => http::not_found(),
}
}
fn create_pass(state: &SharedState, req: &Req) -> Resp {
let data = match serde_json::from_slice::<Value>(&req.body) {
Ok(Value::Object(map)) => map,
Ok(_) => return json(500, &json!({"error": "Erreur: invalid body"})),
Err(e) => {
return json(
500,
&json!({"error": format!("Erreur: FormatException: {e}")}),
);
}
};
let field = |k: &str| {
data.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let (show_title, channel_id, stream_url) = (
field("show_title"),
field("channel_id"),
field("stream_url"),
);
if show_title.is_empty() {
return json(400, &json!({"error": "show_title est requis"}));
}
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot.
let match_mode = if data.get("match_mode").and_then(Value::as_str) == Some("contains") {
"contains"
} else {
"exact"
};
let duplicate = state
.db
.get_season_passes_for_user(&user.id)
.iter()
.any(|p| {
p.get("show_title")
.and_then(Value::as_str)
.is_some_and(|t| t.to_lowercase() == show_title.to_lowercase())
&& p.get("channel_id").and_then(Value::as_str) == Some(channel_id.as_str())
});
if duplicate {
return json(
409,
&json!({"error": "Un Season Pass identique existe déjà"}),
);
}
match state
.db
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
{
Ok(pass) => {
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
json(201, &Value::Object(pass))
}
Err(e) => json(500, &json!({"error": format!("Erreur: {e}")})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exige_un_fuseau() {
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).is_some());
assert_eq!(
parse_zoned_date(Some(&json!("2026-08-27T23:00:00+02:00"))).unwrap(),
parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).unwrap()
);
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00"))).is_none());
assert!(parse_zoned_date(Some(&json!(""))).is_none());
assert!(parse_zoned_date(None).is_none());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Fichiers statiques de la build web, avec politique de cache.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::body::Body;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio_util::io::ReaderStream;
const NO_STORE: [(&str, &str); 3] = [
(
"cache-control",
"no-store, no-cache, must-revalidate, max-age=0",
),
("pragma", "no-cache"),
("expires", "0"),
];
/// Sert la build web en appliquant la politique de cache :
/// - points d'entrée versionnés (`?v=<empreinte>`) servis réécrits, jamais
/// mis en cache ;
/// - `.html`, `.js`, `.json` sans cache pour que les mises à jour soient vues
/// tout de suite, sauf les libs vendorisées (hls.js/mpegts.js, ~750 Ko,
/// versions figées) ;
/// - le reste en cache un jour.
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let path = req.path.as_str();
let key = if path.is_empty() { "index.html" } else { path };
if req.is_get()
&& let Some(content) = state.versioned.get(key)
{
let content_type = if path.ends_with(".js") {
"text/javascript; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
let mut resp = http::with_headers(
http::response(200, content.clone()),
&[("content-type", content_type)],
);
for (name, value) in NO_STORE {
http::set_header(&mut resp, name, value);
}
return resp;
}
let resp = serve_file(&state.web_path, req).await;
let vendored = path.starts_with("vendor/");
if !vendored
&& (path.is_empty()
|| path.ends_with(".html")
|| path.ends_with(".js")
|| path.ends_with(".json"))
{
return http::with_headers(resp, &NO_STORE);
}
http::with_headers(resp, &[("cache-control", "public, max-age=86400")])
}
/// Chemin disque de la requête, ou `None` s'il sort de la racine.
fn resolve(root: &str, path: &str) -> Option<PathBuf> {
let mut resolved = PathBuf::from(root);
for segment in path.split('/').filter(|s| !s.is_empty()) {
let decoded = percent_encoding::percent_decode_str(segment)
.decode_utf8()
.ok()?;
if decoded == "." || decoded == ".." || decoded.contains(['/', '\\', ':', '\0']) {
return None;
}
resolved.push(decoded.as_ref());
}
Some(resolved)
}
fn http_date(time: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = time.into();
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
async fn serve_file(root: &str, req: &Req) -> Resp {
if !req.is_get() {
return http::text(405, "Method Not Allowed");
}
let Some(mut file_path) = resolve(root, &req.path) else {
return http::not_found();
};
if file_path.is_dir() {
// Répertoire sans `/` final : redirection, comme shelf_static.
if !req.path.is_empty() && !req.path.ends_with('/') {
let index = file_path.join("index.html");
if index.is_file() {
let mut resp = http::empty(301);
http::set_header(&mut resp, "location", &format!("/{}/", req.path));
return resp;
}
return http::not_found();
}
file_path = file_path.join("index.html");
}
let Ok(meta) = tokio::fs::metadata(&file_path).await else {
return http::not_found();
};
if !meta.is_file() {
return http::not_found();
}
let modified = meta.modified().ok();
if let (Some(modified), Some(since)) = (modified, req.header("if-modified-since"))
&& let Ok(since) = chrono::DateTime::parse_from_rfc2822(since)
{
let modified: chrono::DateTime<chrono::Utc> = modified.into();
if modified.timestamp() <= since.timestamp() {
return http::empty(304);
}
}
let content_type = content_type_of(&file_path);
let body = if req.method == axum::http::Method::HEAD {
Body::empty()
} else {
match tokio::fs::File::open(&file_path).await {
Ok(file) => Body::from_stream(ReaderStream::new(file)),
Err(_) => return http::not_found(),
}
};
let mut resp = http::response(200, body);
http::set_header(&mut resp, "content-length", &meta.len().to_string());
if let Some(ct) = content_type {
http::set_header(&mut resp, "content-type", &ct);
}
if let Some(modified) = modified {
http::set_header(&mut resp, "last-modified", &http_date(modified));
}
resp
}
fn content_type_of(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
Some(match ext.as_str() {
"js" | "mjs" => "text/javascript".to_string(),
"html" | "htm" => "text/html".to_string(),
"wasm" => "application/wasm".to_string(),
_ => mime_guess::from_ext(&ext).first_raw()?.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuse_la_traversee() {
assert!(resolve("/app/web", "../etc/passwd").is_none());
assert!(resolve("/app/web", "a/%2e%2e/b").is_none());
assert!(resolve("/app/web", "a/..%2Fb").is_none());
assert_eq!(
resolve("/app/web", "vendor/hls.min.js").unwrap(),
PathBuf::from("/app/web").join("vendor").join("hls.min.js")
);
}
}
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
//! Passerelle authentifiée vers `player_api.php` du panneau Xtream.
//!
//! Le front ne voit jamais les identifiants Xtream : il appelle
//! `GET /api/xtream-api?action=...` avec son jeton de session et le serveur
//! injecte username/password de la playlist de l'utilisateur.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::time::Duration;
/// Paramètres que le client peut transmettre à Xtream.
const ALLOWED_PARAMS: [&str; 7] = [
"action",
"category_id",
"stream_id",
"series_id",
"vod_id",
"limit",
"type",
];
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let Some(playlist) = state.get_playlist(req) else {
return http::json(403, &json!({"error": "No playlist configured"}));
};
let Ok(mut target) = reqwest::Url::parse(&format!("{}/player_api.php", playlist.dns)) else {
return http::json(500, &json!({"error": "Upstream Xtream API error"}));
};
{
let mut pairs = target.query_pairs_mut();
pairs.clear();
pairs.append_pair("username", &playlist.username);
pairs.append_pair("password", &playlist.password);
// Ordre et dédoublonnage de la query cliente, comme `queryParameters`.
let mut seen = Vec::new();
for (key, _) in url::form_urlencoded::parse(req.query.as_bytes()) {
if ALLOWED_PARAMS.contains(&key.as_ref()) && !seen.contains(&key) {
seen.push(key);
}
}
let params = req.query_params();
for key in seen {
if let Some(value) = params.get(key.as_ref()) {
pairs.append_pair(&key, value);
}
}
}
let send = state
.http
.get(target.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.header("Accept", "*/*")
.send();
let response = match tokio::time::timeout(Duration::from_secs(90), send).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return upstream_error(&target, &format!("{e:?}")),
Err(_) => return upstream_error(&target, "TimeoutException after 0:01:30.000000"),
};
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let status = response.status().as_u16();
http::with_headers(
http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
),
&[
("content-type", &content_type),
("cache-control", "no-store"),
],
)
}
fn upstream_error(target: &reqwest::Url, error: &str) -> Resp {
println!(
"[XtreamApi] Error forwarding to {}: {}",
redact_url(target.as_str()),
redact_url(error)
);
http::json(500, &json!({"error": "Upstream Xtream API error"}))
}
+252
View File
@@ -0,0 +1,252 @@
//! Dates au format de l'ancien serveur Dart.
//!
//! POURQUOI : la base SQLite de production a été écrite par le serveur Dart.
//! Ses colonnes de dates contiennent la sortie de `DateTime.toIso8601String()`
//! (heure locale sans suffixe pour les sessions et les horodatages de
//! création, UTC avec `Z` pour les fenêtres d'enregistrement), et certaines
//! requêtes les comparent comme des chaînes (`expires_at < ?`,
//! `start_time BETWEEN ? AND ?`). Reproduire exactement ce format garde la
//! base existante lisible et les comparaisons justes, sans migration.
use chrono::{
DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
};
use regex::Regex;
use std::sync::LazyLock;
/// Équivalent d'un `DateTime` Dart : un instant « mural » et un drapeau UTC.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DartDateTime {
pub naive: NaiveDateTime,
pub utc: bool,
}
/// Grammaire de `DateTime.parse` (SDK Dart).
static PARSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"^([+-]?\d{4,6})-?(\d\d)-?(\d\d)",
r"(?:[ T](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d+))?)?)?",
r"( ?[zZ]| ?([-+])(\d\d)(?::?(\d\d))?)?)?$",
))
.unwrap()
});
fn truncate_micros(naive: NaiveDateTime) -> NaiveDateTime {
let micros = naive.and_utc().timestamp_subsec_micros();
naive.with_nanosecond(micros * 1000).unwrap_or(naive)
}
impl DartDateTime {
/// `DateTime.now()` : heure locale, précision microseconde.
pub fn now_local() -> Self {
Self {
naive: truncate_micros(Local::now().naive_local()),
utc: false,
}
}
pub fn from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.naive_utc()),
utc: true,
}
}
/// Instant local (`DateTime.toLocal()`).
pub fn local_from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.with_timezone(&Local).naive_local()),
utc: false,
}
}
/// `DateTime.tryParse`.
pub fn parse(raw: &str) -> Option<Self> {
let caps = PARSE_RE.captures(raw)?;
let num = |i: usize| -> Option<i64> { caps.get(i).map(|m| m.as_str().parse().ok())? };
let year = num(1)? as i32;
let month = num(2)? as u32;
let day = num(3)? as u32;
let hour = num(4).unwrap_or(0) as u32;
let minute = num(5).unwrap_or(0) as u32;
let second = num(6).unwrap_or(0) as u32;
let micros = caps
.get(7)
.map(|m| {
let digits = m.as_str().as_bytes();
let mut value: u32 = 0;
for i in 0..6 {
value *= 10;
if i < digits.len() {
value += (digits[i] - b'0') as u32;
}
}
value
})
.unwrap_or(0);
let date = NaiveDate::from_ymd_opt(year, month, day)?;
// Dart normalise les débordements (24:00, :60) ; on les ajoute en durée.
let base = NaiveTime::from_hms_micro_opt(0, 0, 0, micros)?;
let mut naive = date.and_time(base)
+ chrono::Duration::hours(hour as i64)
+ chrono::Duration::minutes(minute as i64)
+ chrono::Duration::seconds(second as i64);
let utc = caps.get(8).is_some();
if let Some(sign) = caps.get(9) {
let hours = num(10).unwrap_or(0);
let minutes = num(11).unwrap_or(0);
let offset = chrono::Duration::minutes(hours * 60 + minutes);
naive = if sign.as_str() == "-" {
naive + offset
} else {
naive - offset
};
}
Some(Self { naive, utc })
}
/// `DateTime.toIso8601String()`.
pub fn to_iso(self) -> String {
let n = &self.naive;
let year = n.year();
let year_str = if (0..=9999).contains(&year) {
format!("{year:04}")
} else if year < 0 {
format!("-{:06}", -year)
} else {
format!("+{year:06}")
};
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{year_str}-{:02}-{:02}T{:02}:{:02}:{:02}.{ms:03}",
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
if self.utc {
out.push('Z');
}
out
}
/// Instant absolu (`DateTime.toUtc()`).
pub fn to_utc(self) -> DateTime<Utc> {
if self.utc {
return self.naive.and_utc();
}
match Local.from_local_datetime(&self.naive) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&Utc),
chrono::LocalResult::Ambiguous(dt, _) => dt.with_timezone(&Utc),
// Heure qui n'existe pas (passage à l'heure d'été) : décaler d'une
// heure, comme le fait la libc.
chrono::LocalResult::None => Local
.from_local_datetime(&(self.naive + chrono::Duration::hours(1)))
.earliest()
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| self.naive.and_utc()),
}
}
/// `DateTime.toUtc()` gardé au format Dart.
pub fn as_utc(self) -> Self {
Self::from_utc(self.to_utc())
}
pub fn add(self, d: chrono::Duration) -> Self {
if self.utc {
Self::from_utc(self.to_utc() + d)
} else {
Self::local_from_utc(self.to_utc() + d)
}
}
}
/// `DateTime.now().toIso8601String()` (heure locale).
pub fn now_local_iso() -> String {
DartDateTime::now_local().to_iso()
}
/// Instant UTC au format Dart (`...Z`).
pub fn utc_iso(dt: DateTime<Utc>) -> String {
DartDateTime::from_utc(dt).to_iso()
}
/// `'${DateTime.now()}'` : format de `DateTime.toString()` (journaux).
pub fn now_display() -> String {
display_naive(DartDateTime::now_local().naive)
}
/// `'${instant.toLocal()}'` (journaux).
pub fn display_local(dt: DateTime<Utc>) -> String {
display_naive(DartDateTime::local_from_utc(dt).naive)
}
fn display_naive(n: NaiveDateTime) -> String {
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{ms:03}",
n.year(),
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formate_comme_dart() {
let d = DartDateTime::parse("2026-08-11T18:00:00Z").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T18:00:00.123456").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.123456");
let d = DartDateTime::parse("2026-08-11T18:00:00.120").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.120");
}
#[test]
fn lit_le_format_sqlite() {
let d = DartDateTime::parse("2026-10-12 12:03:22").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-10-12T12:03:22.000");
}
#[test]
fn applique_les_decalages() {
let d = DartDateTime::parse("2026-08-11T20:00:00+02:00").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T20:00:00-0130").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T21:30:00.000Z");
}
#[test]
fn rejette_les_valeurs_invalides() {
assert!(DartDateTime::parse("").is_none());
assert!(DartDateTime::parse("abc").is_none());
assert!(DartDateTime::parse("2026-13-01").is_none());
}
}
+913
View File
@@ -0,0 +1,913 @@
//! Base SQLite (`/app/data/xtremflow.db`).
//!
//! Schéma, migrations et formats de dates identiques à l'ancien serveur
//! Dart : la base de production est reprise telle quelle au premier
//! démarrage du serveur Rust.
use crate::dart_time::{DartDateTime, now_local_iso, utc_iso};
use crate::models::{Playlist, Recording, Session, User};
use crate::util::password;
use chrono::{DateTime, Duration, Utc};
use regex::Regex;
use rusqlite::types::ValueRef;
use rusqlite::{Connection, OptionalExtension, Row, params};
use serde_json::{Map, Value};
use std::sync::{LazyLock, Mutex};
pub struct AppDatabase {
conn: Mutex<Connection>,
}
type DbResult<T> = Result<T, rusqlite::Error>;
fn parse_date(raw: Option<String>) -> DartDateTime {
raw.as_deref()
.and_then(DartDateTime::parse)
.unwrap_or_else(DartDateTime::now_local)
}
fn user_from_row(row: &Row) -> DbResult<User> {
Ok(User {
id: row.get("id")?,
username: row.get("username")?,
is_admin: row.get::<_, Option<i64>>("is_admin")?.unwrap_or(0) == 1,
created_at: parse_date(row.get("created_at")?),
})
}
fn playlist_from_row(row: &Row) -> DbResult<Playlist> {
Ok(Playlist {
id: row.get("id")?,
user_id: row.get("user_id")?,
name: row.get("name")?,
server_url: row.get("server_url")?,
username: row.get("username")?,
password: row.get("password")?,
dns: row.get("dns")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
fn recording_from_row(row: &Row) -> DbResult<Recording> {
let text = |name: &str| -> DbResult<String> {
Ok(row.get::<_, Option<String>>(name)?.unwrap_or_default())
};
Ok(Recording {
id: text("id")?,
user_id: text("user_id")?,
channel_id: text("channel_id")?,
stream_url: text("stream_url")?,
title: text("title")?,
start_time: parse_date(row.get("start_time")?),
end_time: parse_date(row.get("end_time")?),
status: row
.get::<_, Option<String>>("status")?
.unwrap_or_else(|| "scheduled".into()),
file_path: row.get("file_path")?,
error_reason: row.get("error_reason")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
/// Ligne brute → objet JSON (colonnes dans l'ordre de la table).
fn row_to_json(row: &Row) -> DbResult<Map<String, Value>> {
let mut map = Map::new();
let stmt = row.as_ref();
for i in 0..stmt.column_count() {
let name = stmt.column_name(i)?.to_string();
let value = match row.get_ref(i)? {
ValueRef::Null => Value::Null,
ValueRef::Integer(v) => Value::from(v),
ValueRef::Real(v) => Value::from(v),
ValueRef::Text(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
ValueRef::Blob(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
};
map.insert(name, value);
}
Ok(map)
}
/// Mot de passe initial sans caractères ambigus (0/O, 1/l/I) : il est
/// recopié depuis les logs du conteneur.
fn generate_password(length: usize) -> String {
const CHARS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut out = String::with_capacity(length);
while out.len() < length {
// Octets aléatoires du générateur système (via UUID v4), tirage par
// rejet pour une distribution uniforme.
for byte in uuid::Uuid::new_v4().into_bytes() {
let limit = 256 - (256 % CHARS.len());
if (byte as usize) < limit && out.len() < length {
out.push(CHARS[byte as usize % CHARS.len()] as char);
}
}
}
out
}
static GPU_SETTING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)"enable_nvidia_gpu"\s*:\s*true"#).unwrap());
impl AppDatabase {
/// Ouvre (ou crée) la base et applique schéma et migrations.
pub fn open(path: &str) -> DbResult<Self> {
if let Some(dir) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(dir);
}
let conn = Connection::open(path)?;
// 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.
conn.execute_batch(
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;",
)?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.run_migrations()?;
println!("Database initialized: {path}");
Ok(db)
}
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|e| e.into_inner())
}
fn create_tables(&self) -> DbResult<()> {
self.lock().execute_batch(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
dns TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tv_recordings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
title TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
file_path TEXT,
error_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS season_passes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
show_title TEXT NOT NULL,
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
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status);
CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time);
CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id);
"#,
)
}
/// 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 a sa migration,
/// numérotée et rejouée uniquement si nécessaire.
fn run_migrations(&self) -> DbResult<()> {
let conn = self.lock();
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)",
[],
)?;
let mut current: i64 = conn.query_row(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version",
[],
|r| r.get(0),
)?;
let migrations: [(i64, &str, &str, &str); 2] = [
// v1 : colonne error_reason absente des bases d'avant son introduction.
(1, "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,
"season_passes",
"match_mode",
"TEXT NOT NULL DEFAULT 'contains'",
),
];
for (version, table, column, definition) in migrations {
if version <= current {
continue;
}
let exists = {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let names = stmt.query_map([], |r| r.get::<_, String>("name"))?;
let mut found = false;
for name in names {
if name? == column {
found = true;
}
}
found
};
if !exists {
conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[],
)?;
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [version])?;
current = version;
println!("[DB] Migration v{version} appliquée");
}
Ok(())
}
/// Crée le compte admin s'il n'existe aucun utilisateur.
///
/// 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.
pub fn seed_admin(&self) -> DbResult<()> {
let count: i64 = self
.lock()
.query_row("SELECT COUNT(*) as count FROM users", [], |r| r.get(0))?;
if count != 0 {
return Ok(());
}
let env_password = std::env::var("ADMIN_INITIAL_PASSWORD").unwrap_or_default();
let generated = env_password.is_empty();
let password = if generated {
generate_password(16)
} else {
env_password
};
let hash = password::hash(&password).expect("bcrypt");
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1)",
params![uuid::Uuid::new_v4().to_string(), "admin", hash],
)?;
if generated {
println!("╔══════════════════════════════════════════════════════════╗");
println!(" Compte admin créé — mot de passe initial (affiché une");
println!(" seule fois, changez-le après la première connexion) :");
println!(" utilisateur: admin");
println!(" mot de passe: {password}");
println!("╚══════════════════════════════════════════════════════════╝");
} else {
println!(
"Default admin user created (username: admin, password: ADMIN_INITIAL_PASSWORD)"
);
}
Ok(())
}
// ==================== Users ====================
pub fn find_user_by_username(&self, username: &str) -> Option<User> {
self.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
user_from_row,
)
.optional()
.ok()
.flatten()
}
pub fn find_user_by_id(&self, user_id: &str) -> Option<User> {
self.lock()
.query_row("SELECT * FROM users WHERE id = ?", [user_id], user_from_row)
.optional()
.ok()
.flatten()
}
/// Vérifie les identifiants. Le hachage bcrypt (coûteux) est fait hors
/// du verrou de la base : à appeler depuis `spawn_blocking`.
pub fn verify_credentials(&self, username: &str, password: &str) -> Option<User> {
let (user, stored) = self
.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
|row| Ok((user_from_row(row)?, row.get::<_, String>("password_hash")?)),
)
.optional()
.ok()
.flatten()?;
if !password::verify(password, &stored) {
return None;
}
// Migration paresseuse : les anciens hachages SHA-256 sont refaits en
// bcrypt à la connexion (seul moment où le mot de passe est connu).
if password::is_legacy(&stored)
&& let Ok(hash) = password::hash(password)
{
self.update_password_hash(&user.id, &hash);
println!("[Auth] Migrated legacy password hash for user: {username}");
}
Some(user)
}
pub fn update_password_hash(&self, user_id: &str, hash: &str) {
let _ = self.lock().execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![hash, user_id],
);
}
/// Crée un utilisateur ; [password_hash] est déjà calculé par l'appelant.
pub fn create_user(
&self,
username: &str,
password_hash: &str,
is_admin: bool,
) -> DbResult<User> {
let id = uuid::Uuid::new_v4().to_string();
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
params![id, username, password_hash, is_admin as i64],
)?;
Ok(User {
id,
username: username.to_string(),
is_admin,
created_at: DartDateTime::now_local(),
})
}
pub fn get_all_users(&self) -> Vec<User> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare("SELECT * FROM users ORDER BY username ASC") else {
return Vec::new();
};
stmt.query_map([], user_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn update_user_admin_status(&self, user_id: &str, is_admin: bool) -> DbResult<()> {
self.lock().execute(
"UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![is_admin as i64, user_id],
)?;
Ok(())
}
pub fn delete_user(&self, user_id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM users WHERE id = ?", [user_id])?;
Ok(())
}
// ==================== Sessions ====================
pub fn create_session(&self, user_id: &str) -> DbResult<Session> {
let token = uuid::Uuid::new_v4().to_string();
let expires_at = DartDateTime::now_local().add(Duration::days(7));
self.lock().execute(
"INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
params![
uuid::Uuid::new_v4().to_string(),
user_id,
token,
expires_at.to_iso()
],
)?;
Ok(Session {
user_id: user_id.to_string(),
token,
expires_at,
})
}
/// Session valide pour [token] ; une session expirée est supprimée.
pub fn find_session_by_token(&self, token: &str) -> Option<Session> {
let session = self
.lock()
.query_row("SELECT * FROM sessions WHERE token = ?", [token], |row| {
Ok(Session {
user_id: row.get("user_id")?,
token: row.get("token")?,
expires_at: parse_date(row.get("expires_at")?),
})
})
.optional()
.ok()
.flatten()?;
if session.is_expired() {
self.delete_session(token);
return None;
}
Some(session)
}
pub fn delete_session(&self, token: &str) {
let _ = self
.lock()
.execute("DELETE FROM sessions WHERE token = ?", [token]);
}
pub fn clean_expired_sessions(&self) {
let _ = self.lock().execute(
"DELETE FROM sessions WHERE expires_at < ?",
[now_local_iso()],
);
}
// ==================== Playlists ====================
pub fn get_playlists(&self, user_id: &str) -> Vec<Playlist> {
let conn = self.lock();
let Ok(mut stmt) =
conn.prepare("SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC")
else {
return Vec::new();
};
stmt.query_map([user_id], playlist_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_playlist_by_id(&self, id: &str) -> Option<Playlist> {
self.lock()
.query_row(
"SELECT * FROM playlists WHERE id = ?",
[id],
playlist_from_row,
)
.optional()
.ok()
.flatten()
}
#[allow(clippy::too_many_arguments)]
pub fn create_playlist(
&self,
user_id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Playlist> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
let now_iso = now.to_iso();
self.lock().execute(
"INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, name, server_url, username, password, dns, now_iso, now_iso],
)?;
Ok(Playlist {
id,
user_id: user_id.into(),
name: name.into(),
server_url: server_url.into(),
username: username.into(),
password: password.into(),
dns: dns.map(str::to_string),
created_at: now,
updated_at: now,
})
}
pub fn update_playlist(
&self,
id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Option<Playlist>> {
self.lock().execute(
"UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ?",
params![name, server_url, username, password, dns, now_local_iso(), id],
)?;
Ok(self.get_playlist_by_id(id))
}
pub fn delete_playlist(&self, id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM playlists WHERE id = ?", [id])?;
Ok(())
}
// ==================== User Settings ====================
pub fn get_user_settings(&self, user_id: &str) -> Option<String> {
self.lock()
.query_row(
"SELECT settings_json FROM user_settings WHERE user_id = ?",
[user_id],
|r| r.get(0),
)
.optional()
.ok()
.flatten()
}
pub fn update_user_settings(&self, user_id: &str, json: &str) -> DbResult<()> {
self.lock().execute(
"INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at",
params![user_id, json, now_local_iso()],
)?;
Ok(())
}
/// Réglage GPU NVIDIA, global au serveur : celui des derniers réglages
/// enregistrés, tous utilisateurs confondus.
pub fn is_nvidia_gpu_enabled(&self) -> bool {
let settings: Option<String> = self
.lock()
.query_row(
"SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1",
[],
|r| r.get(0),
)
.optional()
.ok()
.flatten();
settings.is_some_and(|json| GPU_SETTING.is_match(&json))
}
// ==================== TV Recordings ====================
pub fn create_recording(
&self,
user_id: &str,
channel_id: &str,
stream_url: &str,
title: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> DbResult<Recording> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du
// scheduler et la déduplication des season passes reposent sur ce
// format unique.
let start = DartDateTime::from_utc(start);
let end = DartDateTime::from_utc(end);
self.lock().execute(
"INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
id,
user_id,
channel_id,
stream_url,
title,
start.to_iso(),
end.to_iso(),
now.to_iso(),
now.to_iso()
],
)?;
Ok(Recording {
id,
user_id: user_id.into(),
channel_id: channel_id.into(),
stream_url: stream_url.into(),
title: title.into(),
start_time: start,
end_time: end,
status: "scheduled".into(),
file_path: None,
error_reason: None,
created_at: now,
updated_at: now,
})
}
fn query_recordings(&self, sql: &str, args: &[&str]) -> Vec<Recording> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), recording_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_all_recordings(&self) -> Vec<Recording> {
self.query_recordings("SELECT * FROM tv_recordings ORDER BY start_time ASC", &[])
}
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
pub fn get_pending_recordings(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') ORDER BY start_time ASC",
&[],
)
}
/// Enregistrements terminés, du plus ancien au plus récent (rotation disque).
pub fn get_finished_recordings_oldest_first(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') ORDER BY start_time ASC",
&[],
)
}
pub fn get_user_recordings(&self, user_id: &str) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC",
&[user_id],
)
}
pub fn get_recording_by_id(&self, id: &str) -> Option<Recording> {
self.query_recordings("SELECT * FROM tv_recordings WHERE id = ?", &[id])
.into_iter()
.next()
}
/// Met à jour le statut et éventuellement le chemin d'un enregistrement.
///
/// [clear_error] efface le motif d'erreur au lieu de conserver l'ancien :
/// utile quand un enregistrement précédemment en échec est relancé.
pub fn update_recording_status(
&self,
id: &str,
status: &str,
file_path: Option<&str>,
error_reason: Option<&str>,
clear_error: bool,
) {
let error_expr = if clear_error {
"?"
} else {
"COALESCE(?, error_reason)"
};
let sql = format!(
"UPDATE tv_recordings SET status = ?, file_path = COALESCE(?, file_path), error_reason = {error_expr}, updated_at = ? WHERE id = ?"
);
if let Err(e) = self.lock().execute(
&sql,
params![status, file_path, error_reason, now_local_iso(), id],
) {
println!("[DB] Mise à jour du statut de {id} impossible: {e}");
}
}
/// Réaligne la fenêtre d'un enregistrement planifié (programme déplacé
/// dans l'EPG depuis sa création par un season pass).
pub fn update_recording_window(&self, id: &str, start: DateTime<Utc>, end: DateTime<Utc>) {
let _ = self.lock().execute(
"UPDATE tv_recordings SET start_time = ?, end_time = ?, updated_at = ? WHERE id = ?",
params![utc_iso(start), utc_iso(end), now_local_iso(), id],
);
}
/// Supprime l'enregistrement de la base (pas le fichier).
pub fn delete_recording(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM tv_recordings WHERE id = ?", [id]);
}
// ==================== Season Passes ====================
pub fn create_season_pass(
&self,
user_id: &str,
show_title: &str,
channel_id: &str,
stream_url: &str,
match_mode: &str,
) -> DbResult<Map<String, Value>> {
let id = uuid::Uuid::new_v4().to_string();
let now = now_local_iso();
self.lock().execute(
"INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, show_title, channel_id, stream_url, match_mode, now],
)?;
let mut map = Map::new();
map.insert("id".into(), Value::from(id));
map.insert("user_id".into(), Value::from(user_id));
map.insert("show_title".into(), Value::from(show_title));
map.insert("channel_id".into(), Value::from(channel_id));
map.insert("stream_url".into(), Value::from(stream_url));
map.insert("match_mode".into(), Value::from(match_mode));
map.insert("enabled".into(), Value::from(1));
map.insert("created_at".into(), Value::from(now));
Ok(map)
}
fn query_json(&self, sql: &str, args: &[&str]) -> Vec<Map<String, Value>> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_json)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
/// Season Passes actifs (pour le scheduler).
pub fn get_all_season_passes(&self) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC",
&[],
)
}
pub fn get_season_passes_for_user(&self, user_id: &str) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC",
&[user_id],
)
}
pub fn get_season_pass_by_id(&self, id: &str) -> Option<Map<String, Value>> {
self.query_json("SELECT * FROM season_passes WHERE id = ?", &[id])
.into_iter()
.next()
}
pub fn delete_season_pass(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM season_passes WHERE id = ?", [id]);
}
/// Enregistrement existant pour cet épisode (déduplication des season
/// passes) : même titre, début à ±[tolerance] près.
///
/// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
/// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
pub fn find_recording_for_episode(
&self,
title: &str,
start: DateTime<Utc>,
tolerance: Duration,
) -> Option<Recording> {
let low = utc_iso(start - tolerance);
let high = utc_iso(start + tolerance);
self.query_recordings(
"SELECT * FROM tv_recordings WHERE LOWER(title) = LOWER(?) AND start_time BETWEEN ? AND ? AND status NOT IN ('failed', 'cancelled') LIMIT 1",
&[title, &low, &high],
)
.into_iter()
.next()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_db() -> (AppDatabase, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("xf-test-{}.db", uuid::Uuid::new_v4()));
let db = AppDatabase::open(path.to_str().unwrap()).unwrap();
(db, path)
}
#[test]
fn sessions_et_cascade() {
let (db, _path) = temp_db();
let hash = password::hash("password1").unwrap();
let user = db.create_user("bob", &hash, false).unwrap();
let session = db.create_session(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_some());
assert!(db.verify_credentials("bob", "password1").is_some());
assert!(db.verify_credentials("bob", "nope").is_none());
// ON DELETE CASCADE actif : la session disparaît avec l'utilisateur.
db.delete_user(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_none());
}
#[test]
fn migration_paresseuse_du_sha256() {
let (db, _path) = temp_db();
let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918";
let user = db.create_user("old", legacy, false).unwrap();
assert!(db.verify_credentials("old", "admin").is_some());
let stored: String = db
.lock()
.query_row(
"SELECT password_hash FROM users WHERE id = ?",
[&user.id],
|r| r.get(0),
)
.unwrap();
assert!(!password::is_legacy(&stored));
assert!(db.verify_credentials("old", "admin").is_some());
}
#[test]
fn deduplication_des_episodes_a_deux_minutes_pres() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let start = DartDateTime::parse("2026-08-11T18:00:00Z")
.unwrap()
.to_utc();
let end = start + Duration::hours(1);
db.create_recording(&user.id, "1", "/api/live/1.ts", "Journal", start, end)
.unwrap();
assert!(
db.find_recording_for_episode(
"JOURNAL",
start + Duration::seconds(90),
Duration::minutes(2)
)
.is_some()
);
assert!(
db.find_recording_for_episode(
"Journal",
start + Duration::minutes(5),
Duration::minutes(2)
)
.is_none()
);
let rec = db.get_all_recordings().pop().unwrap();
assert_eq!(rec.start_time.to_iso(), "2026-08-11T18:00:00.000Z");
assert_eq!(rec.status, "scheduled");
}
#[test]
fn statut_et_motif_d_erreur() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let now = Utc::now();
let rec = db
.create_recording(&user.id, "1", "/x", "T", now, now + Duration::hours(1))
.unwrap();
db.update_recording_status(&rec.id, "failed", None, Some("boom"), false);
db.update_recording_status(&rec.id, "failed", Some("/f.mkv"), None, false);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason.as_deref(), Some("boom"));
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
db.update_recording_status(&rec.id, "recording", None, None, true);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason, None);
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
}
#[test]
fn reglage_gpu() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
assert!(!db.is_nvidia_gpu_enabled());
db.update_user_settings(&user.id, r#"{"enable_nvidia_gpu": true}"#)
.unwrap();
assert!(db.is_nvidia_gpu_enabled());
}
#[test]
fn mot_de_passe_genere() {
let p = generate_password(16);
assert_eq!(p.len(), 16);
assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Requête/réponse HTTP internes et petits utilitaires partagés.
//!
//! Le routage reproduit la `Cascade` shelf de l'ancien serveur : chaque
//! étage (API, proxy Xtream, streaming, fichiers statiques) répond ou passe
//! la main par un 404/405. Le corps des requêtes est lu une fois pour toutes
//! en amont, ce qui permet de présenter la même requête à plusieurs étages.
use crate::models::User;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use bytes::Bytes;
use serde_json::Value;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
pub type Resp = axum::response::Response;
#[derive(Clone)]
pub struct Req {
pub method: Method,
/// Chemin sans `/` initial, tel que reçu (encodé), comme `request.url.path`.
pub path: String,
/// Query string brute, sans `?`.
pub query: String,
pub headers: HeaderMap,
pub body: Bytes,
pub remote: Option<SocketAddr>,
/// Renseignés par le middleware d'authentification.
pub user: Option<User>,
pub user_id: Option<String>,
}
impl Req {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
/// Paramètres de query décodés (`+` = espace) ; la dernière occurrence
/// d'une clé l'emporte, comme `Uri.queryParameters`.
pub fn query_params(&self) -> HashMap<String, String> {
url::form_urlencoded::parse(self.query.as_bytes())
.into_owned()
.collect()
}
pub fn query_param(&self, key: &str) -> Option<String> {
self.query_params().remove(key)
}
pub fn is_get(&self) -> bool {
self.method == Method::GET || self.method == Method::HEAD
}
pub fn body_str(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
// ==================== Réponses ====================
pub fn response(status: u16, body: impl Into<Body>) -> Resp {
let mut resp = Resp::new(body.into());
*resp.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
resp
}
/// Corps texte (`text/plain; charset=utf-8`, le défaut de shelf).
pub fn text(status: u16, body: impl Into<String>) -> Resp {
let mut resp = response(status, body.into());
set_header(&mut resp, "content-type", "text/plain; charset=utf-8");
resp
}
pub fn empty(status: u16) -> Resp {
response(status, Body::empty())
}
/// JSON avec l'en-tête `Content-Type: application/json`.
pub fn json(status: u16, value: &Value) -> Resp {
raw_json(status, value.to_string())
}
pub fn raw_json(status: u16, body: String) -> Resp {
let mut resp = response(status, body);
set_header(&mut resp, "content-type", "application/json");
resp
}
/// JSON sans en-tête de type (les `Response.forbidden(jsonEncode(...))` de
/// l'ancien serveur, servis en `text/plain`).
pub fn json_as_text(status: u16, value: &Value) -> Resp {
text(status, value.to_string())
}
pub fn redirect(location: &str) -> Resp {
let mut resp = text(302, format!("Moved to {location}"));
set_header(&mut resp, "location", location);
resp
}
pub fn set_header(resp: &mut Resp, name: &str, value: &str) {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
resp.headers_mut().insert(name, value);
}
}
pub fn with_headers(mut resp: Resp, headers: &[(&str, &str)]) -> Resp {
for (name, value) in headers {
set_header(&mut resp, name, value);
}
resp
}
/// La `Cascade` passe à l'étage suivant sur un 404 ou un 405.
pub fn is_miss(resp: &Resp) -> bool {
matches!(resp.status().as_u16(), 404 | 405)
}
pub fn not_found() -> Resp {
text(404, "Not Found")
}
// ==================== Authentification ====================
/// Jeton de session : en-tête `Authorization: Bearer …` ou cookie `session`.
pub fn extract_token(req: &Req) -> Option<String> {
if let Some(auth) = req.header("authorization")
&& let Some(token) = auth.strip_prefix("Bearer ")
{
return Some(token.to_string());
}
let cookie = req.header("cookie")?;
cookie
.split(';')
.map(str::trim)
.find_map(|part| part.strip_prefix("session=").map(str::to_string))
}
// ==================== Adresse du client ====================
/// 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). TRUSTED_PROXIES (liste
/// d'IP séparées par des virgules) restreint la liste.
pub fn is_trusted_proxy(address: &IpAddr, trusted: &[String]) -> bool {
if !trusted.is_empty() {
return trusted.iter().any(|t| t == &address.to_string());
}
match address {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
}
}
/// Adresse socket au format texte (IPv4 « nue » pour les adresses mappées).
pub fn socket_ip(req: &Req) -> Option<IpAddr> {
req.remote.map(|addr| match addr.ip() {
IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(v6)),
ip => ip,
})
}
/// IP réelle du client.
///
/// 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.
pub fn client_ip(req: &Req, trusted: &[String]) -> String {
let socket = socket_ip(req);
if let (Some(forwarded), Some(ip)) = (req.header("x-forwarded-for"), socket)
&& !forwarded.is_empty()
&& is_trusted_proxy(&ip, trusted)
{
return forwarded.split(',').next().unwrap_or("").trim().to_string();
}
socket
.map(|ip| ip.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Segments d'un sous-chemin monté (`""` → aucun segment).
pub fn segments(rest: &str) -> Vec<&str> {
let rest = rest.trim_start_matches('/');
if rest.is_empty() {
Vec::new()
} else {
rest.split('/').collect()
}
}
/// Sous-chemin d'un montage shelf (`mount('/api/x')`) : `Some("")` pour
/// `api/x` ou `api/x/`, `Some("a/b")` pour `api/x/a/b`, `None` sinon.
pub fn mounted<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
if path == prefix {
return Some("");
}
path.strip_prefix(prefix)?.strip_prefix('/')
}
#[cfg(test)]
mod tests {
use super::*;
fn req_with(headers: &[(&str, &str)], remote: &str) -> Req {
let mut map = HeaderMap::new();
for (k, v) in headers {
map.insert(
HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
Req {
method: Method::GET,
path: String::new(),
query: String::new(),
headers: map,
body: Bytes::new(),
remote: Some(remote.parse().unwrap()),
user: None,
user_id: None,
}
}
#[test]
fn jeton_par_en_tete_ou_cookie() {
assert_eq!(
extract_token(&req_with(&[("authorization", "Bearer abc")], "1.2.3.4:1")),
Some("abc".into())
);
assert_eq!(
extract_token(&req_with(
&[("cookie", "a=1; session=xyz; b=2")],
"1.2.3.4:1"
)),
Some("xyz".into())
);
assert_eq!(extract_token(&req_with(&[], "1.2.3.4:1")), None);
}
#[test]
fn x_forwarded_for_seulement_derriere_un_proxy_de_confiance() {
let direct = req_with(&[("x-forwarded-for", "9.9.9.9")], "8.8.8.8:1");
assert_eq!(client_ip(&direct, &[]), "8.8.8.8");
let proxied = req_with(&[("x-forwarded-for", "9.9.9.9, 10.0.0.1")], "172.18.0.2:1");
assert_eq!(client_ip(&proxied, &[]), "9.9.9.9");
assert_eq!(client_ip(&proxied, &["10.1.1.1".into()]), "172.18.0.2");
}
#[test]
fn montage_shelf() {
assert_eq!(mounted("api/playlists", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/x", "api/playlists"), Some("x"));
assert_eq!(mounted("api/playlistsx", "api/playlists"), None);
}
#[test]
fn parametres_de_query() {
let mut req = req_with(&[], "1.2.3.4:1");
req.query = "a=1&b=x+y&a=2&c=%C3%A9".into();
let params = req.query_params();
assert_eq!(params["a"], "2");
assert_eq!(params["b"], "x y");
assert_eq!(params["c"], "é");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Serveur XtremFlow : API REST, proxy Xtream, streaming FFmpeg,
//! enregistrements TV et fichiers de la build web Flutter.
#![allow(clippy::result_large_err)]
mod api;
mod dart_time;
mod db;
mod http;
mod models;
mod server;
mod services;
mod state;
mod util;
use crate::api::epg::{EpgApi, default_panel_xmltv};
use crate::api::proxy::ProxyHandler;
use crate::api::streaming::Streaming;
use crate::db::AppDatabase;
use crate::server::RateLimiter;
use crate::services::cleanup::CleanupService;
use crate::services::ffmpeg_sessions::FfmpegSessionManager;
use crate::services::logo_catalog::LogoCatalog;
use crate::services::recording_scheduler::RecordingScheduler;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::state::AppState;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_XMLTV: &str = "https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz";
struct Args {
port: u16,
path: String,
}
/// `--port 8089` / `-p 8089` / `--port=8089`, `--path /app/web`.
fn parse_args() -> Args {
let mut args = Args {
port: 8089,
path: "/app/web".to_string(),
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < raw.len() {
let (key, inline) = match raw[i].split_once('=') {
Some((k, v)) => (k.to_string(), Some(v.to_string())),
None => (raw[i].clone(), None),
};
let mut value = || {
inline.clone().or_else(|| {
i += 1;
raw.get(i).cloned()
})
};
match key.as_str() {
"--port" | "-p" => {
if let Some(v) = value() {
args.port = v.parse().expect("--port doit être un entier");
}
}
"--path" => {
if let Some(v) = value() {
args.path = v;
}
}
_ => {}
}
i += 1;
}
args
}
fn http_client(redirects: reqwest::redirect::Policy) -> reqwest::Client {
reqwest::Client::builder()
.redirect(redirects)
.user_agent("VLC/3.0.18 LibVLC/3.0.18")
.connect_timeout(Duration::from_secs(30))
.build()
.expect("client HTTP")
}
#[tokio::main]
async fn main() {
let args = parse_args();
let web_path = args.path.clone();
let db_path =
std::env::var("XTREMFLOW_DB_PATH").unwrap_or_else(|_| "/app/data/xtremflow.db".into());
let db = Arc::new(AppDatabase::open(&db_path).expect("ouverture de la base"));
db.seed_admin().expect("création du compte admin");
let http = http_client(reqwest::redirect::Policy::limited(5));
let http_manual = http_client(reqwest::redirect::Policy::none());
// Planificateur d'enregistrements (les season passes résolvent la
// playlist de leur propriétaire à chaque scan).
let scheduler = Arc::new(RecordingScheduler::new(db.clone(), http.clone()));
scheduler.start();
// Sessions FFmpeg de streaming.
let sessions = Arc::new(FfmpegSessionManager::new(
std::env::temp_dir().join("xtremflow_streams"),
));
sessions.init();
let streaming = Arc::new(Streaming::new(sessions.clone(), http.clone()));
// Source XMLTV de repli. EPG_XMLTV_URLS vide = aucun appel sortant : le
// guide se limite alors au panneau de l'abonné.
let xmltv_urls: Vec<String> = std::env::var("EPG_XMLTV_URLS")
.unwrap_or_else(|_| DEFAULT_XMLTV.to_string())
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect();
if xmltv_urls.is_empty() {
println!("[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)");
} else {
println!("[EPG] Repli XMLTV : {} source(s)", xmltv_urls.len());
}
let xmltv = (!xmltv_urls.is_empty()).then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(xmltv_urls),
http.clone(),
))
});
let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone()));
// Purge des temporaires. Jamais le dossier temporaire système en
// récursif : il contient les sessions HLS.
let cleanup = Arc::new(CleanupService::new());
cleanup.add_target("/app/data/logs");
cleanup.add_target("/app/data/tmp");
cleanup.start();
// Libs de lecture servies depuis l'image : si elles manquent, le lecteur
// échoue côté navigateur sans que rien ne l'ait signalé. On le dit ici.
for lib in ["hls.min.js", "mpegts.min.js"] {
if !std::path::Path::new(&web_path)
.join("vendor")
.join(lib)
.exists()
{
println!(
"[Server] ATTENTION: web/vendor/{lib} absent de {web_path} — la lecture échouera dans le navigateur."
);
}
}
let versioned = util::asset_versioning::build_versioned_entry_points(&web_path);
let mut keys: Vec<&String> = versioned.keys().collect();
keys.sort();
println!(
"[Server] Assets versionnés : {}",
keys.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let state = Arc::new(AppState {
db: db.clone(),
web_path: web_path.clone(),
versioned,
http: http.clone(),
http_manual,
scheduler: scheduler.clone(),
streaming,
proxy: ProxyHandler::new(),
epg,
logo_catalog: Arc::new(LogoCatalog::new(http.clone())),
cleanup,
rate_limit: RateLimiter::new(600),
login_rate_limit: RateLimiter::new(10),
allowed_origin: std::env::var("ALLOWED_ORIGIN")
.ok()
.filter(|o| !o.is_empty()),
trusted_proxies: std::env::var("TRUSTED_PROXIES")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
});
// Sessions expirées purgées toutes les heures.
let db_clean = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
interval.tick().await;
loop {
interval.tick().await;
db_clean.clean_expired_sessions();
println!("Cleaned expired sessions");
}
});
// 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.
tokio::spawn(shutdown_on_signal(scheduler, sessions));
let app = axum::Router::new()
.fallback(server::entry)
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("écoute sur {addr} impossible : {e}"));
println!("Server started on port {}", args.port);
println!("Serving static files from: {web_path}");
println!("REST API available at: /api/auth/* and /api/playlists/*");
println!("Xtream proxy available at: /api/xtream/* (SSRF Protected)");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("serveur HTTP");
}
async fn shutdown_on_signal(
scheduler: Arc<RecordingScheduler>,
sessions: Arc<FfmpegSessionManager>,
) {
let signal = wait_for_signal().await;
println!("[Server] {signal} reçu, arrêt en cours…");
scheduler.shutdown().await;
sessions.kill_all();
// Laisser SIGTERM partir vers les FFmpeg avant de sortir.
tokio::time::sleep(Duration::from_millis(200)).await;
std::process::exit(0);
}
#[cfg(unix)]
async fn wait_for_signal() -> &'static str {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("SIGTERM");
let mut int = signal(SignalKind::interrupt()).expect("SIGINT");
tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
}
}
#[cfg(not(unix))]
async fn wait_for_signal() -> &'static str {
let _ = tokio::signal::ctrl_c().await;
"SIGINT"
}
+121
View File
@@ -0,0 +1,121 @@
//! Modèles de données, sérialisés exactement comme l'ancien serveur Dart
//! (noms de champs, formats de dates) : le front Flutter n'a pas changé.
use crate::dart_time::DartDateTime;
use serde_json::{Map, Value, json};
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub username: String,
pub is_admin: bool,
pub created_at: DartDateTime,
}
impl User {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"username": self.username,
"isAdmin": self.is_admin,
"createdAt": self.created_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Playlist {
pub id: String,
pub user_id: String,
pub name: String,
pub server_url: String,
pub username: String,
pub password: String,
pub dns: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Playlist {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"userId": self.user_id,
"name": self.name,
"serverUrl": self.server_url,
"username": self.username,
// Le mot de passe Xtream n'est jamais exposé aux clients : le
// serveur injecte les identifiants lui-même (voir xtream_api).
"password": "",
"dns": self.dns,
"createdAt": self.created_at.to_iso(),
"updatedAt": self.updated_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Session {
pub user_id: String,
pub token: String,
pub expires_at: DartDateTime,
}
impl Session {
pub fn is_expired(&self) -> bool {
chrono::Utc::now() > self.expires_at.to_utc()
}
}
/// Configuration Xtream utilisée par les proxys et le streaming.
#[derive(Clone, Debug)]
pub struct PlaylistConfig {
/// URL du serveur Xtream (sans `/` final).
pub dns: String,
pub username: String,
pub password: String,
}
#[derive(Clone, Debug)]
pub struct Recording {
pub id: String,
pub user_id: String,
pub channel_id: String,
pub stream_url: String,
pub title: String,
pub start_time: DartDateTime,
pub end_time: DartDateTime,
/// 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
pub status: String,
pub file_path: Option<String>,
pub error_reason: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Recording {
pub fn to_map(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("id".into(), json!(self.id));
map.insert("user_id".into(), json!(self.user_id));
map.insert("channel_id".into(), json!(self.channel_id));
map.insert("stream_url".into(), json!(self.stream_url));
map.insert("title".into(), json!(self.title));
map.insert("start_time".into(), json!(self.start_time.to_iso()));
map.insert("end_time".into(), json!(self.end_time.to_iso()));
map.insert("status".into(), json!(self.status));
map.insert("file_path".into(), json!(self.file_path));
map.insert("error_reason".into(), json!(self.error_reason));
map.insert("created_at".into(), json!(self.created_at.to_iso()));
map.insert("updated_at".into(), json!(self.updated_at.to_iso()));
map
}
pub fn start_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.start_time.to_utc()
}
pub fn end_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.end_time.to_utc()
}
}
+368
View File
@@ -0,0 +1,368 @@
//! Point d'entrée HTTP : middlewares puis cascade API → proxy Xtream →
//! streaming → fichiers statiques, à l'identique de l'ancien serveur.
use crate::api::{account, admin, logo, player_log, recordings, static_files, xtream_api};
use crate::dart_time::now_local_iso;
use crate::http::{self, Req, Resp, client_ip, extract_token, is_miss, mounted, socket_ip};
use crate::state::SharedState;
use crate::util::log_redactor::redact_url;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::Method;
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Corps de requête maximal lu en mémoire.
const MAX_BODY: usize = 16 * 1024 * 1024;
/// Limiteur glissant par IP sur une minute.
pub struct RateLimiter {
limit: usize,
history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl RateLimiter {
pub fn new(limit: usize) -> Self {
let history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>> = Arc::default();
// Ménage toutes les 5 minutes (pas de fuite mémoire par IP).
let h = history.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
interval.tick().await;
loop {
interval.tick().await;
h.lock().unwrap().retain(|_, times| {
times.retain(|t| t.elapsed() < Duration::from_secs(120));
!times.is_empty()
});
}
});
Self { limit, history }
}
/// Enregistre une requête ; `false` si la limite est atteinte.
pub fn allow(&self, ip: &str) -> bool {
let mut history = self.history.lock().unwrap();
let times = history.entry(ip.to_string()).or_default();
while times
.front()
.is_some_and(|t| t.elapsed() >= Duration::from_secs(60))
{
times.pop_front();
}
if times.len() >= self.limit {
return false;
}
times.push_back(Instant::now());
true
}
}
/// Handler axum unique : lit le corps puis applique la chaîne complète.
pub async fn entry(
State(state): State<SharedState>,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
request: Request,
) -> Resp {
let (parts, body) = request.into_parts();
let body = match axum::body::to_bytes(body, MAX_BODY).await {
Ok(b) => b,
Err(_) => return http::text(413, "Request Entity Too Large"),
};
let req = Req {
method: parts.method,
path: parts.uri.path().trim_start_matches('/').to_string(),
query: parts.uri.query().unwrap_or("").to_string(),
headers: parts.headers,
body,
remote: Some(remote),
user: None,
user_id: None,
};
handle(&state, req).await
}
pub async fn handle(state: &SharedState, req: Req) -> Resp {
// Journal masqué : le chemin de /api/xtream/<url> porte les
// identifiants Xtream en clair.
let started = Instant::now();
let log_target = if req.query.is_empty() {
format!("/{}", req.path)
} else {
format!("/{}?{}", req.path, req.query)
};
let method = req.method.clone();
let mut resp = security_chain(state, req).await;
add_security_headers(&mut resp);
println!(
"{} {} {method} {} ({}ms)",
now_local_iso(),
resp.status().as_u16(),
redact_url(&log_target),
started.elapsed().as_millis()
);
resp
}
fn add_security_headers(resp: &mut Resp) {
for (name, value) in [
("x-content-type-options", "nosniff"),
// SAMEORIGIN : les iframes player.html restent intégrables.
("x-frame-options", "SAMEORIGIN"),
("x-xss-protection", "1; mode=block"),
(
"strict-transport-security",
"max-age=63072000; includeSubDomains; preload",
),
("referrer-policy", "strict-origin-when-cross-origin"),
// Report-Only : Flutter Web (CanvasKit/wasm) et google_fonts peuvent
// casser sous une CSP appliquée.
(
"content-security-policy-report-only",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://fonts.gstatic.com",
),
] {
http::set_header(resp, name, value);
}
}
/// Chemins classiques des scanners de vulnérabilités.
const HONEYPOT_PATHS: [&str; 9] = [
"/admin/phpmyadmin",
"/phpmyadmin",
"/wp-admin",
"/wp-login.php",
"/.env",
"/config.php",
"/api/.env",
"/console",
"/actuator/health",
];
async fn security_chain(state: &SharedState, req: Req) -> Resp {
// Honeypot : chemin exact ou préfixe de segment (pas une sous-chaîne
// quelconque, qui bloquait des URL proxifiées légitimes).
let path = format!("/{}", req.path);
for trap in HONEYPOT_PATHS {
if path == trap || path.starts_with(&format!("{trap}/")) {
println!(
"SECURITY ALERT: Honeypot triggered by {} on path: {path}",
client_ip(&req, &state.trusted_proxies)
);
return http::text(403, "Access Denied");
}
}
// Limite globale : 600 req/min/IP (une grille de chaînes déclenche une
// requête de logo et une de guide par tuile, derrière une seule IP de
// reverse proxy).
let ip = client_ip(&req, &state.trusted_proxies);
if ip != "unknown" && ip != "127.0.0.1" && !state.rate_limit.allow(&ip) {
println!("SECURITY WARN: Rate limit exceeded for {ip}");
return http::text(429, "Too Many Requests");
}
cors(state, req).await
}
/// CORS : l'app est servie en même origine ; les en-têtes ne servent qu'au
/// développement (localhost) ou à ALLOWED_ORIGIN. L'origine n'est renvoyée
/// que si elle est autorisée — jamais de joker.
async fn cors(state: &SharedState, req: Req) -> Resp {
let origin = req.header("origin").map(str::to_string);
let allowed = origin.as_deref().is_some_and(|o| {
if state
.allowed_origin
.as_deref()
.is_some_and(|a| !a.is_empty() && a == o)
{
return true;
}
url::Url::parse(o)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|h| h == "localhost" || h == "127.0.0.1")
});
let headers: Vec<(&str, String)> = match (&origin, allowed) {
(Some(o), true) => vec![
("access-control-allow-origin", o.clone()),
(
"access-control-allow-methods",
"GET, POST, PUT, DELETE, OPTIONS".into(),
),
(
"access-control-allow-headers",
"Origin, Content-Type, Accept, Authorization, Range".into(),
),
(
"access-control-expose-headers",
"Content-Length, Content-Range".into(),
),
("access-control-allow-credentials", "true".into()),
("vary", "Origin".into()),
],
_ => Vec::new(),
};
let mut resp = if req.method == Method::OPTIONS {
http::text(200, "")
} else {
cascade(state, req).await
};
for (name, value) in headers {
http::set_header(&mut resp, name, &value);
}
resp
}
/// Cascade shelf : un 404/405 passe la main à l'étage suivant.
async fn cascade(state: &SharedState, req: Req) -> Resp {
let resp = api_router(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = state.proxy.handle(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = protected_streaming(state, &req).await;
if !is_miss(&resp) {
return resp;
}
static_files::handle(state, &req).await
}
/// Middleware d'authentification : session valide exigée, utilisateur
/// attaché à la requête.
fn authenticate(state: &SharedState, req: &Req) -> Result<Req, Resp> {
let Some(token) = extract_token(req) else {
return Err(http::text(401, "Unauthorized"));
};
let Some(session) = state.db.find_session_by_token(&token) else {
return Err(http::text(401, "Invalid or expired session"));
};
let mut authed = req.clone();
authed.user = state.db.find_user_by_id(&session.user_id);
authed.user_id = Some(session.user_id);
Ok(authed)
}
macro_rules! authed {
($state:expr, $req:expr) => {
match authenticate($state, $req) {
Ok(r) => r,
Err(resp) => return resp,
}
};
}
async fn api_router(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
if let Some(rest) = mounted(path, "api/auth") {
// Protection brute force : 10 tentatives de login/min/IP, et 300 ms
// de pénalité par échec.
let is_login = req.method == Method::POST && path.ends_with("login");
if is_login {
let ip = client_ip(req, &state.trusted_proxies);
if !state.login_rate_limit.allow(&ip) {
println!("SECURITY WARN: Login rate limit exceeded for {ip}");
return http::text(429, "Too many login attempts. Try again later.");
}
}
let resp = account::auth(state, req, rest).await;
if is_login && resp.status().as_u16() == 401 {
tokio::time::sleep(Duration::from_millis(300)).await;
}
return resp;
}
// Passerelle Xtream : identifiants injectés côté serveur.
if mounted(path, "api/xtream-api").is_some() {
let req = authed!(state, req);
return xtream_api::handle(state, &req).await;
}
if let Some(rest) = mounted(path, "api/playlists") {
let req = authed!(state, req);
return account::playlists(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/users") {
let req = authed!(state, req);
return account::users(state, &req, rest).await;
}
if let Some(rest) = mounted(path, "api/settings") {
let req = authed!(state, req);
return account::settings(state, &req, rest);
}
// NB : /api/recordings/stream/* traverse ce routeur (404) et atteint le
// streaming plus bas dans la cascade.
if let Some(rest) = mounted(path, "api/recordings") {
let req = authed!(state, req);
return recordings::recordings(state, &req, rest).await;
}
// Logos (le cookie de session accompagne les requêtes d'image).
if path == "api/logo" && req.is_get() {
let req = authed!(state, req);
return logo::handle(state, &req).await;
}
// Incidents de lecture remontés par le lecteur web.
if path == "api/player-log" && req.method == Method::POST {
let _ = authed!(state, req);
return player_log::handle(req);
}
if let Some(rest) = mounted(path, "api/epg") {
let req = authed!(state, req);
return match (req.is_get(), http::segments(rest).as_slice()) {
(true, [channel_id]) => {
let playlist = state.get_playlist(&req);
state.epg.handle(playlist, channel_id).await
}
_ => http::not_found(),
};
}
if let Some(rest) = mounted(path, "api/season-passes") {
let req = authed!(state, req);
return recordings::season_passes(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/admin") {
let req = authed!(state, req);
return admin::admin(state, &req, rest).await;
}
http::not_found()
}
/// Streaming : le cookie de session HttpOnly est accepté (hls.js ne peut pas
/// envoyer d'en-tête Authorization) ; les requêtes FFmpeg locales du
/// planificateur (`http://localhost:8089/api/live/<id>.ts`, sans cookie)
/// passent sans authentification.
async fn protected_streaming(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
let is_streaming = path.starts_with("api/live")
|| path.starts_with("api/vod")
|| path.starts_with("api/recordings/stream");
if !is_streaming {
return http::not_found();
}
let loopback = socket_ip(req).is_some_and(|ip| ip.is_loopback());
let via_proxy = req.headers.contains_key("x-forwarded-for");
if !(loopback && !via_proxy) {
let valid =
extract_token(req).is_some_and(|t| state.db.find_session_by_token(&t).is_some());
if !valid {
return http::text(401, "Unauthorized");
}
}
let streaming = state.streaming.clone();
if let Some(rest) = mounted(path, "api/live") {
return streaming.live(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/vod") {
return streaming.vod(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/recordings/stream") {
return streaming.recording(state, req, rest).await;
}
http::not_found()
}
+135
View File
@@ -0,0 +1,135 @@
//! Purge périodique des fichiers temporaires et des logs de plus de 24 h.
//!
//! Ne JAMAIS cibler le dossier temporaire système en récursif : il contient
//! le dossier des sessions HLS — les fichiers de plus de 24 h y seraient
//! supprimés aveuglément.
use crate::dart_time::now_local_iso;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
pub struct CleanupService {
targets: Mutex<Vec<PathBuf>>,
max_age: Duration,
}
impl CleanupService {
pub fn new() -> Self {
Self {
targets: Mutex::new(Vec::new()),
max_age: Duration::from_secs(24 * 3600),
}
}
/// Ajoute un dossier surveillé (créé s'il manque).
pub fn add_target(&self, dir: &str) {
let path = PathBuf::from(dir);
if !path.exists() {
match std::fs::create_dir_all(&path) {
Ok(()) => println!("CleanupService: Created missing directory: {dir}"),
Err(e) => {
println!(
"CleanupService: Should monitor {dir} but it does not exist and could not be created: {e}"
);
return;
}
}
}
self.targets.lock().unwrap().push(path);
println!("CleanupService: Monitoring {dir}");
}
/// Lance la purge immédiatement puis toutes les 6 heures.
pub fn start(self: &std::sync::Arc<Self>) {
println!("CleanupService: Starting scheduler (Interval: 6h)");
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
loop {
interval.tick().await;
this.run_cleanup().await;
}
});
}
/// Purge manuelle (aussi exposée via /api/admin/purge).
pub async fn run_cleanup(&self) -> Value {
println!("CleanupService: Running cleanup task...");
let targets = self.targets.lock().unwrap().clone();
let max_age = self.max_age;
let (deleted, freed, errors) = tokio::task::spawn_blocking(move || {
let mut deleted = 0u64;
let mut freed = 0u64;
let mut errors = Vec::new();
let threshold = SystemTime::now() - max_age;
for dir in targets {
if !dir.exists() {
continue;
}
match clean_directory(&dir, threshold) {
Ok((count, bytes)) => {
deleted += count;
freed += bytes;
}
Err(e) => {
println!("CleanupService: Error processing {}: {e}", dir.display());
errors.push(format!("Error in {}: {e}", dir.display()));
}
}
}
(deleted, freed, errors)
})
.await
.unwrap_or((0, 0, vec!["cleanup task panicked".to_string()]));
println!(
"CleanupService: Cleanup complete. Deleted {deleted} files, freed {:.2} MB",
freed as f64 / 1024.0 / 1024.0
);
json!({
"status": "complete",
"deleted_files": deleted,
"freed_bytes": freed,
"timestamp": now_local_iso(),
"errors": errors,
})
}
}
/// Supprime récursivement les fichiers modifiés avant [threshold] (sans
/// suivre les liens symboliques).
fn clean_directory(dir: &Path, threshold: SystemTime) -> std::io::Result<(u64, u64)> {
let mut count = 0;
let mut bytes = 0;
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if let Ok((c, b)) = clean_directory(&entry.path(), threshold) {
count += c;
bytes += b;
}
continue;
}
if !file_type.is_file() {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
// Erreurs individuelles ignorées (fichier verrouillé…).
if modified < threshold && std::fs::remove_file(entry.path()).is_ok() {
count += 1;
bytes += meta.len();
}
}
Ok((count, bytes))
}
+367
View File
@@ -0,0 +1,367 @@
//! Registre des processus FFmpeg de transcodage (direct, film, lecture
//! d'enregistrement).
//!
//! Responsabilités :
//! - un processus par identifiant de session (`live_{id}_{qualité}`, …)
//! - le « reaper » tue les sessions inactives au-delà d'un délai (plus aucun
//! spectateur ne demande de segment)
//! - le dossier temporaire HLS est vidé au démarrage (orphelins d'un crash)
//! - l'arrêt du serveur tue tout (pas d'FFmpeg orphelin après `docker stop`)
use crate::util::log_redactor::redact_url;
use crate::util::process::ManagedChild;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tokio::io::{AsyncBufReadExt, BufReader};
const LIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(4 * 60);
const VOD_IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Playlist d'un direct non réécrite depuis ce délai : FFmpeg est bloqué.
const LIVE_STALL_TIMEOUT: Duration = Duration::from_secs(45);
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
fn now_ms() -> u64 {
EPOCH.elapsed().as_millis() as u64
}
/// Une session FFmpeg en cours.
pub struct FfmpegSession {
pub id: String,
pub child: ManagedChild,
pub dir: PathBuf,
pub is_live: bool,
pub started_at: Instant,
last_access_ms: AtomicU64,
/// Dernières lignes de stderr, pour le diagnostic d'un échec rapide.
recent_stderr: Mutex<VecDeque<String>>,
}
impl FfmpegSession {
pub fn touch(&self) {
self.last_access_ms.store(now_ms(), Ordering::Relaxed);
}
/// Temps écoulé depuis la dernière requête de playlist ou de segment.
pub fn idle_for(&self) -> Duration {
Duration::from_millis(now_ms().saturating_sub(self.last_access_ms.load(Ordering::Relaxed)))
}
pub fn exited(&self) -> bool {
self.child.has_exited()
}
pub fn exit_code(&self) -> Option<i32> {
self.child.exit_code()
}
pub fn playlist_path(&self) -> PathBuf {
self.dir.join("playlist.m3u8")
}
fn stderr_text(&self) -> String {
self.recent_stderr
.lock()
.unwrap()
.iter()
.cloned()
.collect::<String>()
.trim()
.to_string()
}
}
pub struct FfmpegSessionManager {
pub base_dir: PathBuf,
sessions: Mutex<HashMap<String, Arc<FfmpegSession>>>,
}
fn playlist_contains(path: &Path, needle: &str) -> bool {
std::fs::read_to_string(path).is_ok_and(|c| c.contains(needle))
}
impl FfmpegSessionManager {
pub fn new(base_dir: PathBuf) -> Self {
Self {
base_dir,
sessions: Mutex::new(HashMap::new()),
}
}
/// Vide les dossiers orphelins et démarre le reaper. Une fois au démarrage.
pub fn init(self: &Arc<Self>) {
if self.base_dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&self.base_dir)
{
println!("[FFmpegManager] Could not wipe temp dir: {e}");
}
let _ = std::fs::create_dir_all(&self.base_dir);
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await;
loop {
interval.tick().await;
this.reap();
}
});
}
pub fn get(&self, id: &str) -> Option<Arc<FfmpegSession>> {
self.sessions.lock().unwrap().get(id).cloned()
}
/// Marque une session comme utilisée (routes de playlist ET de segment).
pub fn touch(&self, id: &str) {
if let Some(session) = self.get(id) {
session.touch();
}
}
pub fn contains(&self, id: &str) -> bool {
self.sessions.lock().unwrap().contains_key(id)
}
fn needs_start_for(existing: Option<&Arc<FfmpegSession>>, is_live: bool) -> bool {
let Some(existing) = existing else {
return true;
};
if !existing.exited() {
return false;
}
// Un transcodage VOD/enregistrement terminé proprement reste lisible
// depuis ses segments : on le réutilise au lieu de retranscoder.
is_live
|| existing.exit_code() != Some(0)
|| !playlist_contains(&existing.playlist_path(), "#EXT-X-ENDLIST")
}
/// Vrai si [get_or_start] lancerait un nouveau processus — donc une
/// nouvelle connexion au fournisseur.
pub fn needs_start(&self, id: &str, is_live: bool) -> bool {
let sessions = self.sessions.lock().unwrap();
Self::needs_start_for(sessions.get(id), is_live)
}
/// Session saine existante, ou nouveau processus FFmpeg. Le dossier de
/// session est recréé pour une nouvelle session ; [args] y est exécuté.
pub fn get_or_start(
&self,
id: &str,
is_live: bool,
ffmpeg_path: &str,
args: &[String],
) -> std::io::Result<Arc<FfmpegSession>> {
let mut sessions = self.sessions.lock().unwrap();
let existing = sessions.get(id).cloned();
if let Some(existing) = &existing {
if !Self::needs_start_for(Some(existing), is_live) {
existing.touch();
return Ok(existing.clone());
}
// Processus mort : nettoyer avant de relancer.
sessions.remove(id);
existing.child.terminate();
let _ = std::fs::remove_dir_all(&existing.dir);
}
let dir = self.base_dir.join(id);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
std::fs::create_dir_all(&dir)?;
let mut child = tokio::process::Command::new(ffmpeg_path)
.args(args)
.current_dir(&dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let stderr = child.stderr.take();
let managed = ManagedChild::spawn(child);
let session = Arc::new(FfmpegSession {
id: id.to_string(),
child: managed,
dir,
is_live,
started_at: Instant::now(),
last_access_ms: AtomicU64::new(now_ms()),
recent_stderr: Mutex::new(VecDeque::new()),
});
sessions.insert(id.to_string(), session.clone());
drop(sessions);
if let Some(stderr) = stderr {
let s = session.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut recent = s.recent_stderr.lock().unwrap();
recent.push_back(format!("{line}\n"));
if recent.len() > 20 {
recent.pop_front();
}
}
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from
// 'http://…' ») : masquer les identifiants Xtream.
println!("[FFmpeg {}] {}", s.id, redact_url(&line));
}
});
}
let s = session.clone();
tokio::spawn(async move {
let code = s.child.wait().await;
println!("[FFmpegManager] Session {} exited with code {code}", s.id);
});
Ok(session)
}
/// Attend que la playlist de la session référence au moins
/// [min_segments] segments. Échoue vite si le processus meurt avant d'avoir
/// produit quoi que ce soit, en renvoyant son stderr récent.
///
/// [min_segments] > 1 donne au lecteur une avance de démarrage. Une
/// playlist déjà close (`#EXT-X-ENDLIST`) est rendue telle quelle.
pub async fn wait_for_playlist(
&self,
session: &FfmpegSession,
min_segments: usize,
) -> (bool, Option<String>) {
let playlist = session.playlist_path();
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(content) = tokio::fs::read_to_string(&playlist).await {
let segments = content.matches(".ts").count();
if segments >= min_segments || (segments > 0 && content.contains("#EXT-X-ENDLIST"))
{
return (true, None);
}
}
if session.exited() {
// Un encodeur terminé proprement avec moins de segments que
// demandé a simplement fini : c'est un succès.
if session.exit_code() == Some(0) && playlist_contains(&playlist, ".ts") {
return (true, None);
}
return (
false,
Some(format!(
"FFmpeg exited ({}): {}",
session.exit_code().unwrap_or(-1),
session.stderr_text()
)),
);
}
// 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.
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Délai écoulé : mieux vaut servir ce qui existe que rien.
if playlist_contains(&playlist, ".ts") {
return (true, None);
}
(false, Some("Timeout waiting for transcoder".to_string()))
}
pub fn kill_session(&self, id: &str) {
let Some(session) = self.sessions.lock().unwrap().remove(id) else {
return;
};
session.child.terminate();
if session.dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&session.dir)
{
println!("[FFmpegManager] Could not delete dir for {id}: {e}");
}
}
/// Tue les sessions sœurs d'un même contenu (`rec_<id>_t0`,
/// `rec_<id>_t600`…) que plus personne ne consomme depuis 30 s.
///
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
/// barre de progression laisserait autant de processus vivants jusqu'au
/// TTL de 15 minutes.
pub fn kill_idle_siblings(&self, prefix: &str, keep: &str) {
let stale: Vec<String> = self
.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.id != keep && s.id.starts_with(prefix))
.filter(|s| s.idle_for() >= Duration::from_secs(30))
.map(|s| s.id.clone())
.collect();
for id in stale {
println!("[FFmpegManager] Dropping stale sibling {id}");
self.kill_session(&id);
}
}
pub fn kill_all(&self) {
let ids: Vec<String> = self.sessions.lock().unwrap().keys().cloned().collect();
println!("[FFmpegManager] Killing {} session(s)", ids.len());
for id in ids {
self.kill_session(&id);
}
}
fn reap(&self) {
let sessions: Vec<Arc<FfmpegSession>> =
self.sessions.lock().unwrap().values().cloned().collect();
for session in sessions {
let timeout = if session.is_live {
LIVE_IDLE_TIMEOUT
} else {
VOD_IDLE_TIMEOUT
};
let idle = session.idle_for();
// Les sessions terminées gardent leurs segments jusqu'au délai
// d'inactivité : un film transcodé est souvent encore regardé.
if idle > timeout {
println!(
"[FFmpegManager] Reaping idle session {} (idle {}s)",
session.id,
idle.as_secs()
);
self.kill_session(&session.id);
continue;
}
// Chien de garde : un direct dont la playlist n'est plus réécrite
// est bloqué sur une source figée. Le tuer laisse la prochaine
// requête du lecteur relancer le transcodeur.
if session.is_live && !session.exited() && Self::is_stalled(&session) {
println!(
"[FFmpegManager] Restarting stalled live session {}",
session.id
);
self.kill_session(&session.id);
}
}
}
fn is_stalled(session: &FfmpegSession) -> bool {
match std::fs::metadata(session.playlist_path()).and_then(|m| m.modified()) {
Ok(modified) => SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age > LIVE_STALL_TIMEOUT),
// Jamais rien produit : délai compté depuis le démarrage.
Err(_) => session.started_at.elapsed() > LIVE_STALL_TIMEOUT,
}
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Repli GPU → CPU des transcodages.
//!
//! Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
//! sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
//! FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
//! repli, le lecteur recevait un 502, relançait… et retombait sur le même
//! GPU saturé : le flux restait illisible tant que la carte était pleine.
use regex::Regex;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
///
/// N'est consulté que pour une session lancée sur le GPU : « opening
/// encoder » y désigne donc forcément h264_nvenc.
static GPU_FAILURE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|out of memory|Device creation failed|hwaccel|opening encoder",
)
.unwrap()
});
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
/// réglerait pas, et qui doublerait inutilement la charge du serveur.
pub fn is_gpu_failure(error: Option<&str>) -> bool {
error.is_some_and(|e| GPU_FAILURE.is_match(e))
}
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
/// leur repli.
pub struct GpuHealth {
cooldown: Duration,
unavailable_until: Mutex<Option<Instant>>,
}
impl GpuHealth {
pub fn new(cooldown: Duration) -> Self {
Self {
cooldown,
unavailable_until: Mutex::new(None),
}
}
pub fn available(&self) -> bool {
self.available_at(Instant::now())
}
pub fn available_at(&self, now: Instant) -> bool {
match *self.unavailable_until.lock().unwrap() {
None => true,
Some(until) => now >= until,
}
}
pub fn mark_failed(&self) {
self.mark_failed_at(Instant::now());
}
pub fn mark_failed_at(&self, now: Instant) {
*self.unavailable_until.lock().unwrap() = Some(now + self.cooldown);
}
}
impl Default for GpuHealth {
fn default() -> Self {
Self::new(Duration::from_secs(120))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detecte_les_pannes_gpu() {
assert!(is_gpu_failure(Some(
"FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: out of memory (10)"
)));
assert!(is_gpu_failure(Some(
"[h264_nvenc @ 0x1] No capable devices found"
)));
assert!(is_gpu_failure(Some(
"cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY"
)));
assert!(is_gpu_failure(Some(
"Error while opening encoder for output stream #0:0 - maybe incorrect parameters such as bit_rate, rate, width or height"
)));
}
#[test]
fn ignore_les_pannes_amont_et_les_delais() {
assert!(!is_gpu_failure(Some(
"FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 Not Found"
)));
assert!(!is_gpu_failure(Some("Timeout waiting for transcoder")));
assert!(!is_gpu_failure(None));
}
#[test]
fn disponible_apres_le_delai() {
let health = GpuHealth::new(Duration::from_secs(120));
let t0 = Instant::now();
assert!(health.available_at(t0));
health.mark_failed_at(t0);
assert!(!health.available_at(t0 + Duration::from_secs(119)));
assert!(health.available_at(t0 + Duration::from_secs(120)));
}
}
+645
View File
@@ -0,0 +1,645 @@
//! Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
//!
//! POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
//! revendeur. Quand cette machine tombe — cas observé : serveur de picons en
//! maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
//! Le dépôt `tv-logos` est maintenu, nommé de façon régulière
//! (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::future::{BoxFuture, FutureExt, Shared};
use regex::Regex;
use reqwest::header::{ACCEPT, USER_AGENT};
use crate::util::log_redactor::redact_url;
/// GitHub refuse les requêtes sans `User-Agent`.
const USER_AGENT_VALUE: &str = "XtremFlow";
/// Plafond d'une requête (liste ou image).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
type SharedListing = Shared<BoxFuture<'static, Option<Arc<Listing>>>>;
/// Catalogue de logos, partagé via `Arc<LogoCatalog>`.
pub struct LogoCatalog {
inner: Arc<Inner>,
}
struct Inner {
client: reqwest::Client,
api_base: String,
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
listing_ttl: Duration,
/// Recul après un échec de téléchargement de liste (limite de débit,
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
retry_backoff: Duration,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
listings: HashMap<&'static str, Arc<Listing>>,
listings_in_flight: HashMap<&'static str, SharedListing>,
listing_failed_at: HashMap<&'static str, Instant>,
/// URL de téléchargement → octets PNG.
bytes: HashMap<String, Bytes>,
}
struct Listing {
/// Clé normalisée → URL de téléchargement brute.
urls: HashMap<String, String>,
fetched_at: Instant,
}
impl Inner {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Libère la place de la requête de liste en cours quand elle se termine,
/// même en cas de panique.
struct InFlightGuard(Arc<Inner>, &'static str);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0.lock().listings_in_flight.remove(self.1);
}
}
impl LogoCatalog {
pub fn new(client: reqwest::Client) -> Self {
Self::with_api_base(client, "https://api.github.com".to_string())
}
/// `api_base` remplace `https://api.github.com` (tests).
pub fn with_api_base(client: reqwest::Client, api_base: String) -> Self {
Self {
inner: Arc::new(Inner {
client,
api_base: api_base.trim_end_matches('/').to_string(),
listing_ttl: Duration::from_secs(24 * 3600),
retry_backoff: Duration::from_secs(15 * 60),
state: Mutex::new(State::default()),
}),
}
}
/// Octets PNG du logo correspondant à `channel_name`, ou `None`.
pub async fn logo_for(&self, channel_name: &str) -> Option<Bytes> {
let country = country_folder_for(channel_name);
let listing = self.listing_for(country).await?;
let key = match_logo_key(channel_name, listing.urls.keys())?;
let url = listing.urls.get(&key)?.clone();
if let Some(cached) = self.inner.lock().bytes.get(&url) {
return Some(cached.clone());
}
let download = async {
let response = self
.inner
.client
.get(&url)
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
response.bytes().await.ok()
};
let bytes = tokio::time::timeout(REQUEST_TIMEOUT, download)
.await
.ok()
.flatten()?;
self.inner.lock().bytes.insert(url, bytes.clone());
Some(bytes)
}
async fn listing_for(&self, country: &'static str) -> Option<Arc<Listing>> {
let pending = {
let mut state = self.inner.lock();
let cached = state.listings.get(country).cloned();
if let Some(listing) = &cached
&& listing.fetched_at.elapsed() < self.inner.listing_ttl
{
return cached;
}
if state
.listing_failed_at
.get(country)
.is_some_and(|at| at.elapsed() < self.inner.retry_backoff)
{
// Liste périmée plutôt que rien : les logos ne changent pas
// d'un jour à l'autre.
return cached;
}
// Les tuiles d'une grille arrivent en rafale : une seule requête
// de liste. Elle tourne dans sa propre tâche pour aller au bout
// même si le premier demandeur abandonne.
match state.listings_in_flight.get(country) {
Some(pending) => pending.clone(),
None => {
let inner = Arc::clone(&self.inner);
let task = tokio::spawn(async move {
let _guard = InFlightGuard(Arc::clone(&inner), country);
fetch_listing(&inner, country).await
});
let pending = async move { task.await.ok().flatten() }.boxed().shared();
state.listings_in_flight.insert(country, pending.clone());
pending
}
}
};
pending.await
}
}
async fn fetch_listing(inner: &Inner, country: &'static str) -> Option<Arc<Listing>> {
let url = format!(
"{}/repos/tv-logo/tv-logos/contents/countries/{country}",
inner.api_base
);
match request_listing(inner, &url).await {
Ok(urls) => {
let count = urls.len();
let listing = Arc::new(Listing {
urls,
fetched_at: Instant::now(),
});
{
let mut state = inner.lock();
state.listings.insert(country, Arc::clone(&listing));
state.listing_failed_at.remove(country);
}
println!("[LogoCatalog] {country} : {count} logos");
Some(listing)
}
Err(error) => {
let stale = {
let mut state = inner.lock();
state.listing_failed_at.insert(country, Instant::now());
state.listings.get(country).cloned()
};
println!(
"[LogoCatalog] liste {country} indisponible : {}",
redact_url(&error)
);
stale
}
}
}
/// Liste des PNG d'un dossier pays : clé normalisée → URL brute.
async fn request_listing(inner: &Inner, url: &str) -> Result<HashMap<String, String>, String> {
let request = async {
let response = inner
.client
.get(url)
.header(ACCEPT, "application/vnd.github+json")
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.map_err(|e| error_text(&e))?;
let status = response.status().as_u16();
if status != 200 {
return Err(format!("Bad state: HTTP {status}"));
}
response.bytes().await.map_err(|e| error_text(&e))
};
let body = tokio::time::timeout(REQUEST_TIMEOUT, request)
.await
.map_err(|_| "TimeoutException after 0:00:20.000000: Future not completed".to_string())??;
let decoded: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&body))
.map_err(|e| format!("FormatException: {e}"))?;
let mut urls = HashMap::new();
if let serde_json::Value::Array(entries) = decoded {
for entry in entries {
let serde_json::Value::Object(entry) = entry else {
continue;
};
let name = entry
.get("name")
.and_then(dart_to_string)
.unwrap_or_default();
let Some(download) = entry.get("download_url").and_then(dart_to_string) else {
continue;
};
if !name.ends_with(".png") {
continue;
}
urls.insert(key_from_file_name(&name), download);
}
}
Ok(urls)
}
/// `value?.toString()` de Dart : `None` pour `null`.
fn dart_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// Texte d'une erreur avec ses causes.
fn error_text(error: &dyn std::error::Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
fn key_from_file_name(name: &str) -> String {
let base = &name[..name.len() - ".png".len()];
let bytes = base.as_bytes();
let n = bytes.len();
if n >= 3
&& bytes[n - 3] == b'-'
&& bytes[n - 2].is_ascii_lowercase()
&& bytes[n - 1].is_ascii_lowercase()
{
base[..n - 3].to_string()
} else {
base.to_string()
}
}
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
fn country_folder(code: &str) -> Option<&'static str> {
Some(match code {
"fr" => "france",
"be" => "belgium",
"ch" => "switzerland",
"uk" | "gb" => "united-kingdom",
"us" => "united-states",
"ca" => "canada",
"de" => "germany",
"es" => "spain",
"it" => "italy",
"pt" => "portugal",
"nl" => "netherlands",
_ => return None,
})
}
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
///
/// `\W` et `\s` reproduits avec leur sens Dart (ASCII pour `\W`, espaces
/// JavaScript pour `\s`), les classes de `regex` étant Unicode.
static COUNTRY_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^[^A-Za-z0-9_]*([A-Za-z]{2})[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}\-:|]+",
)
.unwrap()
});
/// Dossier `tv-logos` à consulter pour `channel_name`. France par défaut :
/// c'est l'audience de l'application.
pub fn country_folder_for(channel_name: &str) -> &'static str {
COUNTRY_PREFIX
.captures(channel_name)
.and_then(|caps| country_folder(&caps[1].to_lowercase()))
.unwrap_or("france")
}
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
/// de flux. Ils sont propres au panneau et absents des noms de logos.
const NOISE: &[&str] = &[
"fhd", "uhd", "hd", "sd", "4k", "8k", "hdr", "hevc", "h264", "h265", "1080p", "720p", "50fps",
"fps", "raw", "backup", "vip", "multi", "vf", "vo", "vostfr",
];
fn fold_accent(c: char) -> char {
match c {
'à' | 'á' | 'â' | 'ä' => 'a',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ò' | 'ó' | 'ô' | 'ö' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
'ç' => 'c',
other => other,
}
}
fn tokens(channel_name: &str) -> Vec<String> {
let mut name = channel_name;
if let Some(caps) = COUNTRY_PREFIX.captures(name)
&& country_folder(&caps[1].to_lowercase()).is_some()
{
name = &name[caps.get(0).map_or(0, |m| m.end())..];
}
let folded: String = name.to_lowercase().chars().map(fold_accent).collect();
folded
.replace('+', " plus ")
.replace('&', " and ")
.split(|c: char| !matches!(c, 'a'..='z' | '0'..='9'))
.filter(|t| !t.is_empty() && !NOISE.contains(t))
.map(str::to_string)
.collect()
}
/// Clé de logo la plus proche de `channel_name` parmi `available`, ou `None`.
///
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
/// France 24.
pub fn match_logo_key<'a>(
channel_name: &str,
available: impl IntoIterator<Item = &'a String>,
) -> Option<String> {
let keys: HashSet<&str> = available.into_iter().map(String::as_str).collect();
let tokens = tokens(channel_name);
if tokens.is_empty() {
return None;
}
let without_live = |parts: &[String]| -> Vec<String> {
parts.iter().filter(|t| *t != "live").cloned().collect()
};
let exact = |parts: &[String]| -> Option<String> {
let slug = parts.join("-");
(!parts.is_empty() && keys.contains(slug.as_str())).then_some(slug)
};
let longer_variant = |parts: &[String]| -> Option<String> {
if parts.len() < 2 {
return None;
}
let prefix = format!("{}-", parts.join("-"));
// Plus courte d'abord (longueur en unités UTF-16 comme en Dart), puis
// ordre alphabétique.
keys.iter()
.filter(|k| k.starts_with(&prefix))
.min_by(|a, b| {
a.encode_utf16()
.count()
.cmp(&b.encode_utf16().count())
.then_with(|| a.cmp(b))
})
.map(|k| k.to_string())
};
let no_live = without_live(&tokens);
let direct = exact(&tokens)
.or_else(|| exact(&no_live))
.or_else(|| longer_variant(&tokens))
.or_else(|| longer_variant(&no_live));
if direct.is_some() {
return direct;
}
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
let last = tokens.last()?;
if !last.is_empty() && last.bytes().all(|b| b.is_ascii_digit()) {
let brand = without_live(&tokens[..tokens.len() - 1]);
if brand.len() >= 2 {
return exact(&brand).or_else(|| longer_variant(&brand));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::http::{StatusCode, Uri};
use std::sync::atomic::{AtomicUsize, Ordering};
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans
/// `-fr.png`).
const FRANCE: &[&str] = &[
"bein-sports-1-french",
"bein-sports-2-french",
"bein-sports",
"canal-plus",
"canal-plus-sport",
"canal-plus-sport-360",
"eurosport-1",
"france-2",
"france-24",
"lequipe",
"rmc-sport-1",
"rmc-sport-2",
"rmc-sport-access-1",
];
fn matched(name: &str) -> Option<String> {
let keys: Vec<String> = FRANCE.iter().map(|s| s.to_string()).collect();
match_logo_key(name, &keys)
}
#[test]
fn correspondance_exacte_apres_nettoyage_du_nom_panneau() {
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
assert_eq!(
matched("FR - RMC SPORT 2 FHD").as_deref(),
Some("rmc-sport-2")
);
assert_eq!(matched("FR: France 2 HD").as_deref(), Some("france-2"));
assert_eq!(
matched("|FR| EUROSPORT 1 UHD").as_deref(),
Some("eurosport-1")
);
}
#[test]
fn traduit_le_plus_de_canal_plus() {
assert_eq!(
matched("FR - CANAL+ SPORT FHD").as_deref(),
Some("canal-plus-sport")
);
assert_eq!(matched("FR - CANAL+").as_deref(), Some("canal-plus"));
}
#[test]
fn accepte_une_variante_plus_longue_du_meme_identifiant() {
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
assert_eq!(
matched("FR - BEIN SPORTS 1 FHD").as_deref(),
Some("bein-sports-1-french")
);
}
#[test]
fn retombe_sur_la_marque_quand_le_numero_de_canal_est_inconnu() {
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
// celui de la marque vaut mieux qu'une tuile vide.
assert_eq!(
matched("FR - RMC SPORT LIVE 5 FHD").as_deref(),
Some("rmc-sport-1")
);
assert_eq!(
matched("FR - CANAL+ SPORT 2").as_deref(),
Some("canal-plus-sport")
);
}
#[test]
fn ne_devine_pas_sur_un_seul_mot() {
// « France » seul ne doit pas donner le logo de France 24.
assert_eq!(matched("FR - FRANCE"), None);
}
#[test]
fn rend_none_quand_rien_ne_correspond() {
assert_eq!(matched("FR - DAZN FHD"), None);
assert_eq!(matched(""), None);
}
#[test]
fn country_folder_lit_le_prefixe_pays_du_nom() {
assert_eq!(country_folder_for("FR - TF1"), "france");
assert_eq!(country_folder_for("BE: RTL TVI"), "belgium");
assert_eq!(country_folder_for("|UK| BBC ONE"), "united-kingdom");
}
#[test]
fn country_folder_france_par_defaut_sans_prefixe_reconnu() {
assert_eq!(country_folder_for("TF1 HD"), "france");
}
#[test]
fn cle_depuis_le_nom_de_fichier() {
assert_eq!(key_from_file_name("rmc-sport-2-fr.png"), "rmc-sport-2");
assert_eq!(key_from_file_name("lequipe.png"), "lequipe");
assert_eq!(key_from_file_name("bbc-one-uk.png"), "bbc-one");
}
/// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les
/// requêtes et répond selon le chemin. `{base}` dans le corps est
/// remplacé par l'adresse du serveur.
async fn serve(
respond: impl Fn(&Uri) -> (StatusCode, String) + Send + Sync + 'static,
) -> (String, Arc<Mutex<Vec<String>>>) {
let requested = Arc::new(Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let respond = Arc::new(respond);
let log = Arc::clone(&requested);
let base_for_body = base.clone();
let app = Router::new().fallback(move |uri: Uri| {
let respond = Arc::clone(&respond);
let log = Arc::clone(&log);
let base = base_for_body.clone();
async move {
log.lock().unwrap().push(uri.to_string());
let (status, body) = respond(&uri);
(status, body.replace("{base}", &base))
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, requested)
}
#[tokio::test]
async fn telecharge_le_logo_apparie_et_le_garde_en_cache() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(
StatusCode::OK,
serde_json::json!([
{"name": "rmc-sport-2-fr.png", "download_url": "{base}/raw/rmc-sport-2-fr.png"},
{"name": "README.md", "download_url": "{base}/r"},
])
.to_string(),
)
} else {
(StatusCode::OK, "\u{1}\u{2}\u{3}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
let first = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
let second = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
assert_eq!(first.as_deref(), Some(&[1u8, 2, 3][..]));
assert_eq!(second.as_deref(), Some(&[1u8, 2, 3][..]));
// Une liste et un logo, pas un aller-retour par affichage.
let requested = requested.lock().unwrap().clone();
assert_eq!(requested.len(), 2, "{requested:?}");
assert_eq!(
requested[0],
"/repos/tv-logo/tv-logos/contents/countries/france"
);
}
#[tokio::test]
async fn rend_none_sans_correspondance_sans_telecharger_d_image() {
let downloads = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&downloads);
let (base, _) = serve(move |uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
counter.fetch_add(1, Ordering::SeqCst);
(StatusCode::OK, "\u{1}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
assert_eq!(catalog.logo_for("FR - CHAINE INCONNUE").await, None);
assert_eq!(downloads.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn espace_les_tentatives_quand_la_liste_est_injoignable() {
let (base, requested) =
serve(|_| (StatusCode::FORBIDDEN, "rate limited".to_string())).await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
catalog.logo_for("FR - RMC SPORT 2").await;
catalog.logo_for("FR - RMC SPORT 1").await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn une_seule_requete_de_liste_pour_une_rafale_de_tuiles() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
(StatusCode::OK, String::new())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
futures::future::join_all((0..8).map(|i| {
let name = format!("FR - CHAINE {i}");
let catalog = &catalog;
async move { catalog.logo_for(&name).await }
}))
.await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod cleanup;
pub mod ffmpeg_sessions;
pub mod gpu_fallback;
pub mod logo_catalog;
pub mod recording_decision;
pub mod recording_scheduler;
pub mod upstream_slots;
pub mod xmltv;
+241
View File
@@ -0,0 +1,241 @@
//! Logique de décision pure du planificateur d'enregistrements, isolée pour
//! être testable.
use chrono::{DateTime, Duration, Utc};
#[derive(Debug, PartialEq, Eq)]
pub enum RecordingAction {
/// Démarrer maintenant.
Start,
/// Capacité atteinte mais fenêtre encore ouverte : réessayer plus tard.
Wait,
/// L'heure de fin est passée avant que la capture ait pu démarrer.
Fail,
/// Pas encore l'heure.
None,
}
/// Que faire d'un enregistrement planifié à ce tick du scheduler ?
pub fn decide_recording_action(
now: DateTime<Utc>,
start: DateTime<Utc>,
end: DateTime<Utc>,
active_count: usize,
max_concurrent: usize,
) -> RecordingAction {
if now > end {
return RecordingAction::Fail;
}
if now <= start {
return RecordingAction::None;
}
if active_count >= max_concurrent {
return RecordingAction::Wait;
}
RecordingAction::Start
}
/// Sort d'un enregistrement resté au statut « recording » en base alors
/// qu'aucun processus FFmpeg ne lui correspond (redémarrage du conteneur…).
#[derive(Debug, PartialEq, Eq)]
pub enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
Resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
Finish,
/// Rien d'exploitable n'a été capturé.
Fail,
}
pub fn decide_orphan_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
has_file: bool,
) -> OrphanAction {
let remaining = end - now;
if remaining > Duration::seconds(60) {
return OrphanAction::Resume;
}
if has_file {
OrphanAction::Finish
} else {
OrphanAction::Fail
}
}
/// Suite à donner quand un processus FFmpeg se termine.
#[derive(Debug, PartialEq, Eq)]
pub enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : terminé.
Complete,
/// FFmpeg s'est arrêté trop tôt : relancer sur la fin de fenêtre.
Retry,
/// Arrêt prématuré sans rien d'exploitable.
Fail,
}
/// Échecs FFmpeg consécutifs tolérés avant d'abandonner un enregistrement
/// dont la fenêtre est encore ouverte. Le compteur est remis à zéro dès
/// qu'un lancement a duré assez longtemps pour être considéré sain.
pub const MAX_FFMPEG_ATTEMPTS: u32 = 30;
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
pub fn decide_post_exit_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
exit_code: i32,
consecutive_failures: u32,
has_file: bool,
) -> PostExitAction {
let remaining = end - now;
if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS {
return PostExitAction::Retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
let clean_exit = exit_code == 0 || exit_code == 255;
if clean_exit || has_file {
PostExitAction::Complete
} else {
PostExitAction::Fail
}
}
/// Durée de capture (`-t`) : le temps restant jusqu'à la fin programmée,
/// et non la durée théorique du programme — un démarrage tardif (ou une
/// relance après coupure) ne doit pas décaler la fin de l'enregistrement.
pub fn capture_duration(now: DateTime<Utc>, end: DateTime<Utc>) -> Duration {
let remaining = end - now;
let minimum = Duration::seconds(30);
if remaining < minimum {
minimum
} else {
remaining
}
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
pub fn ffmpeg_retry_delay(consecutive_failures: u32) -> std::time::Duration {
let steps = (consecutive_failures as i64 - 1).clamp(0, 4) as u32;
let seconds = 3u64 * (1 << steps);
std::time::Duration::from_secs(seconds.min(30))
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> DateTime<Utc> {
"2026-08-27T21:00:00Z".parse().unwrap()
}
#[test]
fn actions_de_planification() {
let n = now();
let m = Duration::minutes;
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 0, 2),
RecordingAction::Start
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 2, 2),
RecordingAction::Wait
);
assert_eq!(
decide_recording_action(n, n - m(120), n - m(5), 0, 2),
RecordingAction::Fail
);
assert_eq!(
decide_recording_action(n, n + m(30), n + m(60), 0, 2),
RecordingAction::None
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 1, 2),
RecordingAction::Start
);
}
#[test]
fn actions_orphelines() {
let n = now();
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), true),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), false),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), true),
OrphanAction::Finish
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), false),
OrphanAction::Fail
);
assert_eq!(
decide_orphan_action(n, n + Duration::seconds(20), true),
OrphanAction::Finish
);
}
#[test]
fn actions_apres_sortie() {
let n = now();
let m40 = n + Duration::minutes(40);
assert_eq!(
decide_post_exit_action(n, m40, 1, 0, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 0, 2, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 0, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, false),
PostExitAction::Fail
);
}
#[test]
fn duree_de_capture() {
let n = now();
assert_eq!(
capture_duration(n, n + Duration::minutes(58)),
Duration::minutes(58)
);
assert_eq!(
capture_duration(n, n - Duration::minutes(5)),
Duration::seconds(30)
);
}
#[test]
fn delai_de_relance() {
let s = std::time::Duration::from_secs;
assert_eq!(ffmpeg_retry_delay(1), s(3));
assert_eq!(ffmpeg_retry_delay(2), s(6));
assert_eq!(ffmpeg_retry_delay(3), s(12));
assert_eq!(ffmpeg_retry_delay(4), s(24));
assert_eq!(ffmpeg_retry_delay(5), s(30));
assert_eq!(ffmpeg_retry_delay(50), s(30));
assert_eq!(ffmpeg_retry_delay(0), s(3));
}
}
File diff suppressed because it is too large. Load diff
+429
View File
@@ -0,0 +1,429 @@
//! Connexions ouvertes vers le fournisseur Xtream, par compte.
//!
//! POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
//! simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
//! lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
//! ou la fin du téléchargement pour un film, le temps de détecter la
//! déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
//! le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
//! films « indisponibles » et les zaps qui échouent.
//!
//! Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
//! connexions ORPHELINES du même compte (plus personne ne les consomme),
//! les plus anciennes d'abord, jusqu'à lui faire de la place.
//!
//! Un flux encore regardé n'est jamais coupé : deux lecteurs ouverts sur un
//! compte à une connexion se coupaient sinon en boucle (constaté en prod).
//! Le second lecteur est refusé par le panneau et le premier continue.
//!
//! Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
use crate::models::PlaylistConfig;
use futures::future::BoxFuture;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
type Release = Box<dyn Fn() + Send + Sync>;
type IsActive = Box<dyn Fn() -> bool + Send + Sync>;
struct Slot {
token: u64,
account: String,
/// Ordre d'inscription : les plus anciennes connexions partent d'abord.
since: u64,
release: Release,
is_active: Option<IsActive>,
}
impl Slot {
fn active(&self) -> bool {
self.is_active.as_ref().is_some_and(|f| f())
}
}
#[derive(Default)]
struct Inner {
slots: HashMap<String, Slot>,
next: u64,
}
#[derive(Default)]
pub struct UpstreamSlots {
inner: Mutex<Inner>,
}
impl UpstreamSlots {
pub fn new() -> Self {
Self::default()
}
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
///
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
/// le même identifiant ne doit pas être désinscrite par la fin de
/// l'ancien processus. [is_active] : vrai tant qu'un spectateur consomme
/// le flux ; une connexion active n'est jamais coupée.
pub fn register(
&self,
id: &str,
account: &str,
release: Release,
is_active: Option<IsActive>,
) -> u64 {
let mut inner = self.inner.lock().unwrap();
inner.next += 1;
let token = inner.next;
inner.slots.insert(
id.to_string(),
Slot {
token,
account: account.to_string(),
since: token,
release,
is_active,
},
);
token
}
pub fn unregister(&self, id: &str, token: Option<u64>) {
let mut inner = self.inner.lock().unwrap();
let Some(slot) = inner.slots.get(id) else {
return;
};
if token.is_some_and(|t| t != slot.token) {
return;
}
inner.slots.remove(id);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn count_for(&self, account: &str) -> usize {
self.inner
.lock()
.unwrap()
.slots
.values()
.filter(|s| s.account == account)
.count()
}
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
///
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
/// d'interrompre un autre spectateur.
pub fn make_room(&self, account: &str, max: i64, keep: &str) -> Vec<String> {
if max <= 0 {
return Vec::new();
}
let released: Vec<(String, Release)> = {
let mut inner = self.inner.lock().unwrap();
let others: Vec<&String> = inner
.slots
.iter()
.filter(|(id, s)| s.account == account && id.as_str() != keep)
.map(|(id, _)| id)
.collect();
let excess = others.len() as i64 - (max - 1);
if excess <= 0 {
return Vec::new();
}
let mut orphans: Vec<(String, u64)> = others
.into_iter()
.filter(|id| !inner.slots[*id].active())
.map(|id| (id.clone(), inner.slots[id].since))
.collect();
orphans.sort_by_key(|(_, since)| *since);
orphans
.into_iter()
.take(excess as usize)
.filter_map(|(id, _)| inner.slots.remove(&id).map(|s| (id, s.release)))
.collect()
};
// Coupures hors du verrou : un `release` peut toucher d'autres registres.
released
.into_iter()
.map(|(id, release)| {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(&release));
id
})
.collect()
}
}
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
/// même quota chez le fournisseur.
pub fn account_key_of(p: &PlaylistConfig) -> String {
format!("{}|{}", p.dns, p.username)
}
/// `user_info.max_connections` d'une réponse `player_api.php`, ou `None`.
pub fn parse_max_connections(json: &serde_json::Value) -> Option<i64> {
let raw = json.get("user_info")?.as_object()?.get("max_connections")?;
let value = match raw {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}?;
(value > 0).then_some(value)
}
pub type FetchLimit = Arc<dyn Fn(PlaylistConfig) -> BoxFuture<'static, Option<i64>> + Send + Sync>;
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
///
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
/// répondre (mesuré en prod). La valeur connue (même périmée), sinon
/// [FALLBACK], est rendue immédiatement ; la lecture chez le panneau se fait
/// en arrière-plan. Sans risque : le quota ne sert qu'à décider quels flux
/// ORPHELINS couper.
pub struct AccountLimits {
ttl: Duration,
fetch: FetchLimit,
cache: Arc<Mutex<HashMap<String, (i64, Instant)>>>,
refreshing: Arc<Mutex<HashSet<String>>>,
}
impl AccountLimits {
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
/// cas de loin le plus courant chez les fournisseurs Xtream.
pub const FALLBACK: i64 = 1;
pub fn new(ttl: Duration, fetch: FetchLimit) -> Self {
Self {
ttl,
fetch,
cache: Arc::default(),
refreshing: Arc::default(),
}
}
/// Lecture réelle chez le panneau.
pub fn from_panel(client: reqwest::Client) -> Self {
let fetch: FetchLimit = Arc::new(move |p: PlaylistConfig| {
let client = client.clone();
Box::pin(async move {
let mut url = reqwest::Url::parse(&format!("{}/player_api.php", p.dns)).ok()?;
url.query_pairs_mut()
.append_pair("username", &p.username)
.append_pair("password", &p.password);
let send = client
.get(url)
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(5), send)
.await
.ok()?
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
let body = tokio::time::timeout(Duration::from_secs(5), response.bytes())
.await
.ok()?
.ok()?;
let json: serde_json::Value = serde_json::from_slice(&body).ok()?;
parse_max_connections(&json)
})
});
Self::new(Duration::from_secs(600), fetch)
}
pub fn max_for(&self, p: &PlaylistConfig) -> i64 {
let key = account_key_of(p);
let cached = self.cache.lock().unwrap().get(&key).copied();
if cached.is_none_or(|(_, at)| at.elapsed() >= self.ttl) {
self.refresh(key, p.clone());
}
cached.map(|(max, _)| max).unwrap_or(Self::FALLBACK)
}
fn refresh(&self, key: String, p: PlaylistConfig) {
if !self.refreshing.lock().unwrap().insert(key.clone()) {
return; // une seule lecture à la fois
}
let fetch = self.fetch.clone();
let cache = self.cache.clone();
let refreshing = self.refreshing.clone();
tokio::spawn(async move {
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
if let Some(value) = fetch(p).await {
cache
.lock()
.unwrap()
.insert(key.clone(), (value, Instant::now()));
}
refreshing.lock().unwrap().remove(&key);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn collector() -> (Arc<Mutex<Vec<String>>>, impl Fn(&str) -> Release) {
let released = Arc::new(Mutex::new(Vec::new()));
let r = released.clone();
let make = move |id: &str| -> Release {
let r = r.clone();
let id = id.to_string();
Box::new(move || r.lock().unwrap().push(id.clone()))
};
(released, make)
}
#[test]
fn compte_a_une_connexion_le_flux_precedent_est_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert_eq!(
slots.make_room("acc", 1, "live_2_source"),
vec!["live_1_source"]
);
assert_eq!(*released.lock().unwrap(), vec!["live_1_source"]);
assert_eq!(slots.count_for("acc"), 0);
}
#[test]
fn la_session_demandee_n_est_jamais_coupee() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert!(slots.make_room("acc", 1, "live_1_source").is_empty());
assert!(released.lock().unwrap().is_empty());
}
#[test]
fn les_plus_anciennes_partent_d_abord() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
for id in ["a", "b", "c"] {
slots.register(id, "acc", make(id), None);
}
assert_eq!(slots.make_room("acc", 3, "new"), vec!["a"]);
assert_eq!(slots.count_for("acc"), 2);
}
#[test]
fn un_autre_compte_n_est_jamais_touche() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("x", "other", make("x"), None);
assert!(slots.make_room("acc", 1, "new").is_empty());
assert_eq!(slots.count_for("other"), 1);
}
#[test]
fn jeton_perime_ne_libere_pas_la_session_relancee() {
let slots = UpstreamSlots::new();
let old = slots.register("s", "acc", Box::new(|| {}), None);
slots.register("s", "acc", Box::new(|| {}), None);
slots.unregister("s", Some(old));
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn un_flux_regarde_n_est_jamais_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
assert!(slots.make_room("acc", 1, "new").is_empty());
assert!(released.lock().unwrap().is_empty());
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn seuls_les_orphelins_partent() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
slots.register("orphan", "acc", make("orphan"), None);
assert_eq!(slots.make_room("acc", 1, "new"), vec!["orphan"]);
assert_eq!(*released.lock().unwrap(), vec!["orphan"]);
}
#[test]
fn quota_inconnu_rien_n_est_coupe() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("a", "acc", make("a"), None);
assert!(slots.make_room("acc", 0, "new").is_empty());
}
fn playlist() -> PlaylistConfig {
PlaylistConfig {
dns: "http://panel.test".into(),
username: "u".into(),
password: "x".into(),
}
}
#[tokio::test]
async fn ne_fait_jamais_attendre_un_flux() {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<i64>>();
let rx = Arc::new(Mutex::new(Some(rx)));
let fetch: FetchLimit = Arc::new(move |_| {
let rx = rx.lock().unwrap().take();
Box::pin(async move {
match rx {
Some(rx) => rx.await.ok().flatten(),
None => None,
}
})
});
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tx.send(Some(3)).unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 3);
}
#[tokio::test]
async fn valeur_perimee_servie_pendant_le_rafraichissement() {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let fetch: FetchLimit = Arc::new(move |_| {
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
Box::pin(async move { Some(if n == 1 { 2 } else { 5 }) })
});
let limits = AccountLimits::new(Duration::ZERO, fetch);
limits.max_for(&playlist());
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 2);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 5);
}
#[tokio::test]
async fn panneau_en_echec_repli_sans_exception() {
let fetch: FetchLimit = Arc::new(|_| Box::pin(async { None }));
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
}
#[test]
fn lit_max_connections() {
use serde_json::json;
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "1"}})),
Some(1)
);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": 3}})),
Some(3)
);
assert_eq!(parse_max_connections(&json!({"user_info": {}})), None);
assert_eq!(parse_max_connections(&json!("oops")), None);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "0"}})),
None
);
}
}
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
//! État partagé du serveur.
use crate::api::epg::EpgApi;
use crate::api::proxy::ProxyHandler;
use crate::api::streaming::Streaming;
use crate::db::AppDatabase;
use crate::http::Req;
use crate::models::PlaylistConfig;
use crate::server::RateLimiter;
use crate::services::cleanup::CleanupService;
use crate::services::logo_catalog::LogoCatalog;
use crate::services::recording_scheduler::RecordingScheduler;
use std::collections::HashMap;
use std::sync::Arc;
pub struct AppState {
pub db: Arc<AppDatabase>,
pub web_path: String,
/// Points d'entrée réécrits avec `?v=<empreinte>` (asset_versioning).
pub versioned: HashMap<String, String>,
/// Client HTTP qui suit les redirections.
pub http: reqwest::Client,
/// Client HTTP sans suivi des redirections (revalidées à la main).
pub http_manual: reqwest::Client,
pub scheduler: Arc<RecordingScheduler>,
pub streaming: Arc<Streaming>,
pub proxy: ProxyHandler,
pub epg: EpgApi,
pub logo_catalog: Arc<LogoCatalog>,
pub cleanup: Arc<CleanupService>,
pub rate_limit: RateLimiter,
pub login_rate_limit: RateLimiter,
pub allowed_origin: Option<String>,
pub trusted_proxies: Vec<String>,
}
impl AppState {
/// Playlist Xtream de la requête : la première playlist de l'utilisateur
/// authentifié ; à défaut (proxy, streaming), celle du premier
/// utilisateur de la base.
pub fn get_playlist(&self, req: &Req) -> Option<PlaylistConfig> {
let playlist = match &req.user {
Some(user) => self.db.get_playlists(&user.id).into_iter().next(),
None => {
let users = self.db.get_all_users();
users
.first()
.and_then(|u| self.db.get_playlists(&u.id).into_iter().next())
}
};
match playlist {
Some(p) => Some(PlaylistConfig {
dns: p.server_url,
username: p.username,
password: p.password,
}),
None => {
println!("[getPlaylist] WARNING: No playlist found, returning null");
None
}
}
}
}
pub type SharedState = Arc<AppState>;
+135
View File
@@ -0,0 +1,135 @@
//! Versionnage des assets non hachés de la build web.
//!
//! POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
//! gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
//! `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
//! en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
//! l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
//! déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
//! chaque version a sa propre URL : aucun cache intermédiaire ne peut la
//! confondre avec la précédente.
use md5::{Digest, Md5};
use std::collections::HashMap;
use std::path::Path;
/// Empreinte courte et stable de [bytes].
pub fn content_version(bytes: &[u8]) -> String {
let digest = Md5::digest(bytes);
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
hex[..12].to_string()
}
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
///
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
/// référence déjà versionnée restent intactes.
pub fn version_asset_refs(content: &str, versions: &[(&str, String)]) -> String {
let mut out = content.to_string();
for (asset, version) in versions {
for quote in ['"', '\''] {
let from = format!("{quote}{asset}{quote}");
let to = format!("{quote}{asset}?v={version}{quote}");
out = out.replace(&from, &to);
}
}
out
}
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
/// être servis à la place des fichiers d'origine.
///
/// Calculé une fois au démarrage : la build web est figée dans l'image.
pub fn build_versioned_entry_points(web_path: &str) -> HashMap<String, String> {
let base = Path::new(web_path);
let read = |name: &str| std::fs::read_to_string(base.join(name)).ok();
let version_of = |name: &str| {
std::fs::read(base.join(name))
.ok()
.map(|b| content_version(&b))
};
let mut rewritten = HashMap::new();
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
// de main.dart.js, d'où une version calculée APRÈS réécriture.
if let (Some(bootstrap), Some(main_version)) =
(read("flutter_bootstrap.js"), version_of("main.dart.js"))
{
let versioned = version_asset_refs(&bootstrap, &[("main.dart.js", main_version)]);
let bootstrap_version = content_version(versioned.as_bytes());
rewritten.insert("flutter_bootstrap.js".to_string(), versioned);
if let Some(index) = read("index.html") {
rewritten.insert(
"index.html".to_string(),
version_asset_refs(&index, &[("flutter_bootstrap.js", bootstrap_version)]),
);
}
}
let player_assets: Vec<(&str, String)> = ["xf-player-core.js", "xf-player.css"]
.into_iter()
.filter_map(|name| version_of(name).map(|v| (name, v)))
.collect();
for page in ["player.html", "player_lite.html", "player_mobile.html"] {
if let Some(html) = read(page) {
rewritten.insert(page.to_string(), version_asset_refs(&html, &player_assets));
}
}
rewritten
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versionne_la_balise_script_du_lecteur() {
assert_eq!(
version_asset_refs(
r#"<script src="xf-player-core.js"></script>"#,
&[("xf-player-core.js", "abc123".into())]
),
r#"<script src="xf-player-core.js?v=abc123"></script>"#
);
}
#[test]
fn versionne_main_js_path_dans_la_config_flutter() {
assert_eq!(
version_asset_refs(
r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}"#,
&[("main.dart.js", "f00".into())]
),
r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}"#
);
}
#[test]
fn ne_touche_ni_un_nom_contenant_l_asset_ni_une_ref_versionnee() {
let html = r#"<script src="vendor/xf-player-core.js"></script><script src="xf-player-core.js?v=old"></script>"#;
assert_eq!(
version_asset_refs(html, &[("xf-player-core.js", "new".into())]),
html
);
}
#[test]
fn guillemets_simples_acceptes() {
assert_eq!(
version_asset_refs(
"<link href='xf-player.css'>",
&[("xf-player.css", "1".into())]
),
"<link href='xf-player.css?v=1'>"
);
}
#[test]
fn empreinte_stable() {
assert_eq!(content_version(&[1, 2, 3]), content_version(&[1, 2, 3]));
assert_ne!(content_version(&[1, 2, 3]), content_version(&[1, 2, 4]));
assert_eq!(content_version(&[1, 2, 3]).len(), 12);
}
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more