mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-13 18:27:05 +02:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530bf82b04 | ||
|
|
3ea6fa0ae3 | ||
|
|
f82c70ae05 | ||
|
|
6733f80ffd | ||
|
|
0539ad857f | ||
|
|
a739d6451c | ||
|
|
775caad0ee | ||
|
|
4d00409de8 | ||
|
|
0ea60ccbfa | ||
|
|
a3640a6b5f | ||
|
|
584068b45b | ||
|
|
abe7d522c1 | ||
|
|
8d79472ca8 | ||
|
|
6812a4964d | ||
|
|
fdf98f9659 | ||
|
|
6212948ea5 | ||
|
|
662e1034e5 | ||
|
|
a9b43ff581 | ||
|
|
327873d254 | ||
|
|
da67c479b3 | ||
|
|
9f4e98fcea | ||
|
|
117523f153 | ||
|
|
08d841180f | ||
|
|
73469060ff | ||
|
|
e19afd00f1 |
No files matched your search
+1
-2
@@ -6,8 +6,7 @@
|
||||
.packages
|
||||
.pub-cache
|
||||
build/
|
||||
bin/.dart_tool
|
||||
bin/.packages
|
||||
server/target
|
||||
ios/
|
||||
android/
|
||||
windows/
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
# fil du temps (une release Flutter peut casser la CI sans aucun
|
||||
# changement dans le dépôt). Le cache évite de retélécharger SDK et
|
||||
# dépendances pub à chaque run.
|
||||
flutter-version: 3.38.4
|
||||
flutter-version: 3.47.6
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
@@ -31,18 +31,27 @@ jobs:
|
||||
# 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: 3.13.2
|
||||
- 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
|
||||
@@ -52,4 +52,7 @@ app.*.map.json
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## 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
|
||||
|
||||
+43
-10
@@ -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,11 +31,10 @@ ENV FLUTTER_NO_ANALYTICS=1
|
||||
RUN flutter config --enable-web && flutter precache --web
|
||||
|
||||
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
||||
# Les lockfiles sont versionnés : le build résout exactement les versions
|
||||
# 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 ./
|
||||
COPY bin/pubspec.yaml bin/pubspec.lock ./bin/
|
||||
RUN flutter pub get && cd bin && dart pub get
|
||||
RUN flutter pub get
|
||||
|
||||
# 3. Source Code Copy (CHANGES OFTEN)
|
||||
# Everything except what's in .dockerignore (built-in protection)
|
||||
@@ -29,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 \
|
||||
@@ -62,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
@@ -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"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# XtremFlow — Application Web IPTV
|
||||
|
||||
Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, empaquetés dans une seule image Docker. Se connecte à un abonnement Xtream Codes et ajoute le magnétoscope (enregistrements planifiés, season passes), l'EPG avec repli XMLTV, et le transcodage FFmpeg à la demande.
|
||||
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.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
@@ -16,7 +16,7 @@ Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, em
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) |
|
||||
| Backend | Dart compilé en natif (`dart compile exe`), shelf |
|
||||
| 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** |
|
||||
@@ -37,7 +37,7 @@ Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléa
|
||||
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
||||
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
||||
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
|
||||
| `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 |
|
||||
@@ -52,25 +52,28 @@ flutter pub get
|
||||
flutter analyze && flutter test
|
||||
flutter run -d chrome # nécessite le backend lancé pour les routes /api
|
||||
|
||||
# Backend
|
||||
cd bin
|
||||
dart pub get
|
||||
dart analyze && dart test
|
||||
dart run server.dart --port 8089 --path ../build/web
|
||||
# Backend (Rust ≥ 1.98)
|
||||
cd server
|
||||
cargo test
|
||||
cargo clippy -- -D warnings
|
||||
XTREMFLOW_DB_PATH=./dev.db cargo run -- --port 8089 --path ../build/web
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
Le build Windows natif est documenté dans `BUILD.md`.
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
bin/ Serveur Dart
|
||||
├── server.dart Point d'entrée, routage, arrêt gracieux
|
||||
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…)
|
||||
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV
|
||||
├── database/ SQLite (schéma, migrations)
|
||||
├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés)
|
||||
└── test/ Tests backend (dart test)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Réponse pour une image indisponible.
|
||||
///
|
||||
/// Statut 410 et non 404 : la `Cascade` du serveur retombe sur le handler
|
||||
/// suivant pour tout 404 et renvoie finalement celui du handler statique, sans
|
||||
/// notre `cache-control`. Un 410 s'arrête ici.
|
||||
///
|
||||
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
|
||||
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
|
||||
/// est essentiel : sans lui le navigateur redemande le logo à chaque
|
||||
/// affichage de la grille et le flot de requêtes mortes reprend aussitôt.
|
||||
Response missingImageResponse() => Response(
|
||||
410,
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Mémoire courte des hôtes d'images injoignables.
|
||||
///
|
||||
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
|
||||
/// sur le panneau. Quand cette machine tombe (maintenance, DNS, quota), elle
|
||||
/// répond 5xx en quelques millisecondes, le navigateur réessaie à chaque
|
||||
/// rendu de la grille et le proxy relaie des centaines de requêtes mortes par
|
||||
/// seconde. Le serveur tournant sur un unique isolate Dart, ce flot passe
|
||||
/// devant les paquets vidéo dans la boucle d'événements : l'image saccade
|
||||
/// pendant qu'on s'acharne sur un hôte qu'on sait hors service.
|
||||
///
|
||||
/// On coupe donc court : au bout de [threshold] échecs consécutifs, l'hôte
|
||||
/// est considéré mort pendant [ttl] et les requêtes suivantes sont servies
|
||||
/// localement, sans appel sortant.
|
||||
class AssetFailureCache {
|
||||
AssetFailureCache({
|
||||
this.threshold = 3,
|
||||
this.ttl = const Duration(minutes: 5),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
/// Nombre d'échecs consécutifs avant de couper un hôte. Un 5xx passager ne
|
||||
/// doit pas priver l'utilisateur de ses logos.
|
||||
final int threshold;
|
||||
|
||||
/// Durée de la coupure. Assez courte pour qu'un hôte réparé revienne seul.
|
||||
final Duration ttl;
|
||||
|
||||
final DateTime Function() _clock;
|
||||
|
||||
final Map<String, int> _failures = {};
|
||||
final Map<String, DateTime> _downUntil = {};
|
||||
|
||||
/// L'hôte est-il réputé hors service en ce moment ?
|
||||
bool isDown(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final until = _downUntil[key];
|
||||
if (until == null) return false;
|
||||
if (_clock().isBefore(until)) return true;
|
||||
|
||||
// Coupure expirée : on repart d'une ardoise vierge pour laisser une
|
||||
// vraie chance au prochain appel.
|
||||
_downUntil.remove(key);
|
||||
_failures.remove(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
void recordFailure(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final count = (_failures[key] ?? 0) + 1;
|
||||
_failures[key] = count;
|
||||
if (count >= threshold) {
|
||||
_downUntil[key] = _clock().add(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
void recordSuccess(String host) {
|
||||
final key = host.toLowerCase();
|
||||
_failures.remove(key);
|
||||
_downUntil.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,401 +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';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
///
|
||||
/// Le panneau de l'abonné reste la source de référence, mais par son dump
|
||||
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
||||
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
||||
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
||||
/// en dernier recours — cas des revendeurs dont le guide est figé depuis
|
||||
/// plusieurs jours.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
/// 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 = {};
|
||||
|
||||
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
||||
final http.Client _http;
|
||||
|
||||
/// Fabrique de la source XMLTV du panneau, une par compte.
|
||||
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
||||
|
||||
/// Index XMLTV du panneau, par compte.
|
||||
final Map<String, XmltvEpgService> _panelXmltv = {};
|
||||
|
||||
EpgApi(
|
||||
this._getPlaylist, {
|
||||
XmltvEpgService? xmltv,
|
||||
http.Client? httpClient,
|
||||
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
||||
}) : _xmltv = xmltv,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
||||
|
||||
/// Dump XMLTV servi par le panneau lui-même.
|
||||
///
|
||||
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
||||
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
||||
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
||||
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
||||
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
||||
/// clients IPTV rapides.
|
||||
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
||||
final user = Uri.encodeQueryComponent(playlist.username);
|
||||
final password = Uri.encodeQueryComponent(playlist.password);
|
||||
return XmltvEpgService(
|
||||
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
||||
refreshInterval: const Duration(hours: 3),
|
||||
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
||||
// plusieurs minutes sur un panneau qui traîne.
|
||||
downloadTimeout: const Duration(seconds: 90),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
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 {
|
||||
// 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.
|
||||
//
|
||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
||||
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||
// secondes pièce.
|
||||
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||
];
|
||||
|
||||
var epgData = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': <Map<String, dynamic>>[],
|
||||
};
|
||||
var source = 'xtream';
|
||||
var found = false;
|
||||
|
||||
for (final (name, fetch) in sources) {
|
||||
final programmes = await fetch();
|
||||
if (programmes.isEmpty) continue;
|
||||
|
||||
final candidate = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': programmes,
|
||||
};
|
||||
if (!found) {
|
||||
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
found = true;
|
||||
}
|
||||
if (_hasCurrentProgramme(candidate)) {
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final jsonStr = json.encode(epgData);
|
||||
|
||||
// Mettre en cache 30 minutes
|
||||
_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) {
|
||||
// Détail redacté en log uniquement : une ClientException Dart contient
|
||||
// l'URI amont, credentials Xtream inclus.
|
||||
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
||||
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final service =
|
||||
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
||||
return _fromXmltv(service, playlist, channelId);
|
||||
}
|
||||
|
||||
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
||||
///
|
||||
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
||||
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
||||
/// est absent ou ne couvre pas la chaîne.
|
||||
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = ['get_simple_data_table', 'get_short_epg'];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final response =
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final programmes = (parsed['programmes'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (programmes.isNotEmpty) return programmes;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Le guide contient-il un programme en cours ou à venir ?
|
||||
///
|
||||
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
|
||||
/// 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,
|
||||
) =>
|
||||
_fromXmltv(_xmltv, playlist, channelId);
|
||||
|
||||
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
||||
/// source externe), ou liste vide si l'index ne la connaît pas.
|
||||
Future<List<Map<String, dynamic>>> _fromXmltv(
|
||||
XmltvEpgService? xmltv,
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
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] source 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) {
|
||||
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
||||
return {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
'error': 'EPG indisponible',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../services/logo_catalog.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// GET /api/logo?src=<stream_icon>&name=<nom de chaîne>
|
||||
///
|
||||
/// Logo d'une chaîne, avec repli. L'URL fournie par le panneau est tentée
|
||||
/// d'abord, via le proxy Xtream (mêmes protections anti-SSRF, même mémoire
|
||||
/// des hôtes morts). En cas d'échec, le logo est cherché par nom dans le
|
||||
/// dépôt public `tv-logos`.
|
||||
///
|
||||
/// POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
|
||||
/// perd ses logos d'un coup, alors que les autres applications IPTV les
|
||||
/// affichent encore grâce à leurs propres sources de repli.
|
||||
class LogoApi {
|
||||
LogoApi(this._proxy, this._catalog);
|
||||
|
||||
/// Proxy Xtream (`ProxyHandler.handler`), qui attend `/api/xtream/<url>`.
|
||||
final Handler _proxy;
|
||||
|
||||
final LogoCatalog _catalog;
|
||||
|
||||
/// Un logo trouvé change rarement : un jour de cache navigateur.
|
||||
static const _found = 'public, max-age=86400';
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final src = request.url.queryParameters['src'] ?? '';
|
||||
final name = request.url.queryParameters['name'] ?? '';
|
||||
|
||||
// Une URL panneau malformée ne doit pas priver la chaîne du repli.
|
||||
final proxied = src.startsWith('http://') || src.startsWith('https://')
|
||||
? Uri.tryParse('${request.requestedUri.origin}/api/xtream/$src')
|
||||
: null;
|
||||
|
||||
if (proxied != null) {
|
||||
final upstream = await _proxy(
|
||||
Request(
|
||||
'GET',
|
||||
proxied,
|
||||
headers: {
|
||||
for (final header in const ['cookie', 'authorization'])
|
||||
if (request.headers[header] != null)
|
||||
header: request.headers[header]!,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final type = upstream.headers['content-type'] ?? '';
|
||||
if (upstream.statusCode == 200 && type.startsWith('image/')) {
|
||||
return Response.ok(
|
||||
upstream.read(),
|
||||
headers: {'content-type': type, 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
// Corps d'erreur consommé : sinon la connexion amont reste ouverte.
|
||||
await upstream.read().drain<void>();
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
final bytes = await _catalog.logoFor(name);
|
||||
if (bytes != null) {
|
||||
return Response.ok(
|
||||
bytes,
|
||||
headers: {'content-type': 'image/png', 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return missingImageResponse();
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../middleware/auth_middleware.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// L'URL désigne-t-elle une image ou un logo ?
|
||||
///
|
||||
/// Ces URL échappent à l'allowlist de domaine (les revendeurs hébergent leurs
|
||||
/// picons ailleurs que le panneau) et méritent un repli visuel plutôt qu'une
|
||||
/// erreur propagée au navigateur.
|
||||
bool _isStaticAssetUrl(Uri url) =>
|
||||
url.path.endsWith('.png') ||
|
||||
url.path.endsWith('.jpg') ||
|
||||
url.path.endsWith('.jpeg') ||
|
||||
url.path.endsWith('.gif') ||
|
||||
url.path.endsWith('.webp') ||
|
||||
url.path.endsWith('.ico') ||
|
||||
url.path.contains('/picons/') ||
|
||||
url.path.contains('/logos/');
|
||||
|
||||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||||
/// 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 AppDatabase _db;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||
static const _cacheDuration = Duration(minutes: 5);
|
||||
|
||||
/// Hôtes d'images réputés hors service (voir [AssetFailureCache]).
|
||||
final AssetFailureCache _assetFailures = AssetFailureCache();
|
||||
|
||||
static const _allowedHeaders = [
|
||||
'content-type',
|
||||
'content-range',
|
||||
'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, this._db);
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
// Authentification par session. Les requêtes initiées par le navigateur
|
||||
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
|
||||
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
|
||||
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
|
||||
// rebond SSRF non authentifié vers n'importe quel hôte public via les
|
||||
// extensions d'image.
|
||||
final token = extractAuthToken(request);
|
||||
if (token == null || _db.findSessionByToken(token) == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
|
||||
Uri? targetUrl;
|
||||
|
||||
// === 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 = _isStaticAssetUrl(targetUrl);
|
||||
|
||||
// Hôte d'images déjà constaté mort : on répond tout de suite.
|
||||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||||
// flot qu'on cherche à éteindre.
|
||||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
String? allowedHost;
|
||||
if (!isStaticAsset) {
|
||||
// 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();
|
||||
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())}');
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
if (request.headers.containsKey(header)) {
|
||||
proxyHeaders[header] = request.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
List<int>? postBody;
|
||||
if (request.method == 'POST') {
|
||||
final bodyBytes = await request.read().toList();
|
||||
postBody = bodyBytes.expand((i) => i).toList();
|
||||
}
|
||||
|
||||
// Redirections suivies MANUELLEMENT : chaque destination est
|
||||
// revalidée (hôte privé, allowlist de domaine). Avec
|
||||
// followRedirects, la validation ne portait que sur l'URL
|
||||
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||||
// un hôte interne malgré l'anti-SSRF.
|
||||
http.StreamedResponse response;
|
||||
var currentUrl = targetUrl;
|
||||
var redirects = 0;
|
||||
while (true) {
|
||||
final proxyRequest = http.Request(request.method, currentUrl);
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = false;
|
||||
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||||
|
||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||
response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
final location = response.headers['location'];
|
||||
final isRedirect = response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
location != null;
|
||||
if (!isRedirect) break;
|
||||
|
||||
if (++redirects > 3) {
|
||||
return Response.forbidden('Too many redirects');
|
||||
}
|
||||
final next = Uri.parse(location);
|
||||
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||||
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported redirect scheme');
|
||||
}
|
||||
if (isForbiddenProxyHost(currentUrl.host)) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||||
);
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
if (allowedHost != null &&
|
||||
currentUrl.host.toLowerCase() != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||||
// rendu. On répond un 404 mis en cache, et on compte l'échec.
|
||||
if (isStaticAsset) {
|
||||
if (response.statusCode >= 400) {
|
||||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||||
// redirection : c'est cette clé-là que consulte le garde-fou
|
||||
// en tête de requête.
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||||
// reste ouverte jusqu'au timeout.
|
||||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||||
return missingImageResponse();
|
||||
}
|
||||
_assetFailures.recordSuccess(targetUrl.host);
|
||||
}
|
||||
|
||||
// Build response headers from source response
|
||||
final responseHeaders = <String, String>{
|
||||
'access-control-allow-origin': '*',
|
||||
'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) {
|
||||
// Redaction : une ClientException porte l'URL amont, credentials
|
||||
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||||
print(
|
||||
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Image injoignable : 404 mis en cache plutôt qu'une erreur 500
|
||||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Proxy error'}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -1,290 +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/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
final AppDatabase _db;
|
||||
final RecordingScheduler _scheduler;
|
||||
|
||||
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
|
||||
final int maxRecordingHours = int.tryParse(
|
||||
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
|
||||
) ??
|
||||
12;
|
||||
|
||||
RecordingsApi(this._db, this._scheduler);
|
||||
|
||||
Response _json(int status, Map<String, dynamic> body) => Response(
|
||||
status,
|
||||
body: json.encode(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
|
||||
/// (même patron de contrôle de propriété que playlists_handler)
|
||||
bool _canAccess(User? user, Recording recording) {
|
||||
if (user == null) return false;
|
||||
return user.isAdmin || recording.userId == user.id;
|
||||
}
|
||||
|
||||
/// Handler pour GET /api/recordings/logs/<id>
|
||||
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
||||
Future<Response> getLogHandler(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
if (recording.filePath == null) {
|
||||
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
|
||||
}
|
||||
|
||||
// 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 _json(403, {'error': 'Chemin de log invalide'});
|
||||
}
|
||||
|
||||
final logFile = File(safeLogPath);
|
||||
|
||||
if (!await logFile.exists()) {
|
||||
return _json(404, {'error': 'Le fichier de log est introuvable.'});
|
||||
}
|
||||
|
||||
final logs = await logFile.readAsString();
|
||||
return _json(200, {'logs': logs});
|
||||
}
|
||||
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminée.
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
if (r.status == 'recording') {
|
||||
final start = r.startTime.toUtc();
|
||||
final end = r.endTime.toUtc();
|
||||
final total = end.difference(start).inSeconds;
|
||||
if (total > 0) {
|
||||
final elapsed = now.difference(start).inSeconds;
|
||||
map['progress_pct'] =
|
||||
(elapsed * 100 / total).clamp(0, 100).round();
|
||||
}
|
||||
map['is_active'] = _scheduler.isCapturing(r.id);
|
||||
final retries = _scheduler.retryCountOf(r.id);
|
||||
if (retries != null) map['retry_count'] = retries;
|
||||
}
|
||||
|
||||
final path = r.filePath;
|
||||
if (path != null) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// POST /api/recordings — Planifie un nouvel enregistrement
|
||||
Future<Response> handlePost(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return _json(401, {'error': 'Authentification requise'});
|
||||
}
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return _json(400, {'error': 'Corps JSON invalide'});
|
||||
}
|
||||
|
||||
final channelId = data['channel_id']?.toString() ?? '';
|
||||
final streamUrl = data['stream_url']?.toString() ?? '';
|
||||
if (channelId.isEmpty) {
|
||||
return _json(400, {'error': 'channel_id est requis'});
|
||||
}
|
||||
if (streamUrl.isEmpty) {
|
||||
return _json(400, {'error': 'stream_url est requis'});
|
||||
}
|
||||
|
||||
final startTime = _parseZonedDate(data['start_time']);
|
||||
final endTime = _parseZonedDate(data['end_time']);
|
||||
if (startTime == null || endTime == null) {
|
||||
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
|
||||
// l'interpréter dans le fuseau du serveur décale l'enregistrement
|
||||
// de plusieurs heures selon le TZ du conteneur.
|
||||
return _json(400, {
|
||||
'error':
|
||||
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
|
||||
'(ex: 2026-08-27T21:00:00Z)',
|
||||
});
|
||||
}
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
return _json(400, {'error': 'end_time doit être après start_time'});
|
||||
}
|
||||
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
|
||||
return _json(400, {
|
||||
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final recording = _db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: data['title']?.toString() ?? 'Sans Titre',
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
recording.toJson(),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[RecordingsApi] Erreur à la création: $e');
|
||||
return _json(500, {'error': 'Erreur lors de la programmation'});
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
|
||||
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
|
||||
DateTime? _parseZonedDate(dynamic raw) {
|
||||
final str = raw?.toString() ?? '';
|
||||
if (str.isEmpty) return null;
|
||||
// 'Z' final ou offset ±hh[:mm] après l'heure
|
||||
final hasZone =
|
||||
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
|
||||
if (!hasZone) return null;
|
||||
return DateTime.tryParse(str)?.toUtc();
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
|
||||
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
|
||||
Future<Response> handleDelete(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
|
||||
await _scheduler.stopRecording(id);
|
||||
|
||||
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
|
||||
// en restant confiné au dossier des enregistrements.
|
||||
final path = recording.filePath;
|
||||
if (path != null) {
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath != null) {
|
||||
await _scheduler.deleteRecordingFiles(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
_db.deleteRecording(id);
|
||||
|
||||
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
|
||||
}
|
||||
|
||||
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
||||
Future<Response> handleStop(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
final stopped = await _scheduler.stopRecording(id);
|
||||
if (stopped) {
|
||||
return _json(200, {'message': 'Enregistrement arrêté'});
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
|
||||
// completed sans fichier faisait ensuite échouer la lecture.
|
||||
_db.updateRecordingStatus(id, 'cancelled');
|
||||
return _json(200, {'message': 'Enregistrement annulé'});
|
||||
}
|
||||
|
||||
if (recording.status == 'recording') {
|
||||
// Statut « recording » sans processus actif (orphelin) : clôturer.
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
|
||||
}
|
||||
|
||||
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
|
||||
return _json(200, {'message': 'Enregistrement déjà clôturé'});
|
||||
}
|
||||
}
|
||||
@@ -1,155 +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 les season passes de l'utilisateur
|
||||
/// (tous les passes pour un admin)
|
||||
Response handleGetAll(Request request) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
final passes = user.isAdmin
|
||||
? _db.getAllSeasonPasses()
|
||||
: _db.getSeasonPassesForUser(user.id);
|
||||
return Response.ok(
|
||||
json.encode(passes),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur au listage: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
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?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
||||
// programmes qui contiennent le mot. 'contains' reste disponible.
|
||||
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
|
||||
|
||||
// Vérifier si un season pass identique existe déjà pour cet utilisateur
|
||||
final existing = _db.getSeasonPassesForUser(user.id);
|
||||
final duplicate = existing.any(
|
||||
(p) =>
|
||||
(p['show_title'] as String).toLowerCase() ==
|
||||
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: user.id,
|
||||
showTitle: showTitle,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
matchMode: matchMode,
|
||||
);
|
||||
|
||||
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 {
|
||||
final user = request.context['user'] as User?;
|
||||
final pass = _db.getSeasonPassById(id);
|
||||
if (pass == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Season Pass non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
|
||||
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Accès refusé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
_db.deleteSeasonPass(id);
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Season Pass supprimé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur à la suppression: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,893 +0,0 @@
|
||||
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 '../services/gpu_fallback.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
final Directory _hlsTempDir =
|
||||
Directory('${Directory.systemTemp.path}/xtremflow_streams');
|
||||
|
||||
/// Global FFmpeg session registry (initialized in [initStreaming]).
|
||||
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
|
||||
|
||||
/// Pannes GPU récentes, partagées par toutes les routes de transcodage.
|
||||
final GpuHealth _gpuHealth = GpuHealth();
|
||||
|
||||
/// 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',
|
||||
]
|
||||
: [
|
||||
// veryfast au lieu de medium : en live, l'encodeur doit tenir
|
||||
// le temps réel ET produire le premier segment vite ; medium
|
||||
// ajoutait plusieurs secondes au zap sans gain visible à ce
|
||||
// débit.
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||
'-profile:v', 'high', '-level', '4.0',
|
||||
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
||||
'-pix_fmt', 'yuv420p', '-g', '50',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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',
|
||||
};
|
||||
|
||||
typedef _SessionAttempt = ({FfmpegSession? session, bool ready, String? error});
|
||||
|
||||
/// Démarre (ou récupère) la session [id] et attend sa playlist.
|
||||
///
|
||||
/// Un `Process.start` qui échoue (serveur à court de processus ou de
|
||||
/// mémoire) devient un échec ordinaire au lieu d'une exception non
|
||||
/// rattrapée qui remontait en 500.
|
||||
Future<_SessionAttempt> _runSession({
|
||||
required String id,
|
||||
required bool isLive,
|
||||
required List<String> Function(Directory dir) argsBuilder,
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final FfmpegSession session;
|
||||
try {
|
||||
session = await sessionManager.getOrStart(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: argsBuilder,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
return (session: null, ready: false, error: 'FFmpeg start failed: $e');
|
||||
}
|
||||
final outcome =
|
||||
await sessionManager.waitForPlaylist(session, minSegments: minSegments);
|
||||
return (session: session, ready: outcome.ready, error: outcome.error);
|
||||
}
|
||||
|
||||
/// Comme [_runSession], avec repli sur le CPU si l'encodage GPU échoue.
|
||||
///
|
||||
/// Le repli garde le même identifiant de session : les requêtes de segments
|
||||
/// qui suivent tombent sur la session CPU sans que le lecteur s'en aperçoive.
|
||||
/// Une panne GPU fait aussi partir les sessions suivantes directement sur le
|
||||
/// CPU pendant le délai de [GpuHealth], au lieu d'échouer une à une.
|
||||
Future<_SessionAttempt> _runWithGpuFallback({
|
||||
required String id,
|
||||
required bool isLive,
|
||||
required bool wantGpu,
|
||||
required List<String> Function(bool gpu) buildArgs,
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final gpu = wantGpu && _gpuHealth.available;
|
||||
var attempt = await _runSession(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
argsBuilder: (_) => buildArgs(gpu),
|
||||
minSegments: minSegments,
|
||||
);
|
||||
|
||||
if (!attempt.ready && gpu && isGpuFailure(attempt.error)) {
|
||||
_gpuHealth.markFailed();
|
||||
print('[GPU] $id : encodage GPU refusé '
|
||||
'(${LogRedactor.redactUrl(attempt.error ?? '')}) — repli sur le CPU');
|
||||
sessionManager.killSession(id);
|
||||
attempt = await _runSession(
|
||||
id: id,
|
||||
isLive: isLive,
|
||||
argsBuilder: (_) => buildArgs(false),
|
||||
minSegments: minSegments,
|
||||
);
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 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 result = await _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: true,
|
||||
wantGpu: useNvidiaGpu && quality != 'source',
|
||||
buildArgs: (gpu) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (gpu) ...['-hwaccel', 'cuda'],
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-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',
|
||||
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
||||
// secondes à sonder le flux avant d'écrire le premier segment.
|
||||
'-fflags', 'nobuffer',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', targetUrl,
|
||||
..._liveVideoArgs(quality, gpu),
|
||||
..._audioArgs(withFilters: false),
|
||||
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
||||
// previous 20-segment window while still safe for iOS).
|
||||
// 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 session = result.session;
|
||||
if (!result.ready || session == null) {
|
||||
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}/turbo.ts — flux MPEG-TS continu pour le
|
||||
// player web (mpegts.js) : vidéo copiée telle quelle, audio TOUJOURS
|
||||
// réencodé en AAC.
|
||||
//
|
||||
// POURQUOI : mpegts.js ne démuxe que l'AAC et le MP3. Les chaînes qui
|
||||
// diffusent en AC-3/E-AC-3 (0x81/0x87) ou en MPEG-1 Layer II passaient par
|
||||
// le proxy brut → image sans aucun son. Le réencodage audio seul coûte
|
||||
// quelques % de CPU (pas de transcodage vidéo) et garde le zapping
|
||||
// instantané : un seul flux continu, pas de segmentation HLS à amorcer.
|
||||
//
|
||||
// Latence : -fflags nobuffer + probesize réduit → première image en
|
||||
// ~0,5-1,5 s au lieu des 2-6 s d'analyse par défaut de FFmpeg.
|
||||
router.get('/<streamId>/turbo.ts', (Request request, String streamId) async {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid stream ID');
|
||||
}
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null) return Response.forbidden('No playlist');
|
||||
|
||||
final targetUrl =
|
||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
||||
print(
|
||||
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||
);
|
||||
|
||||
final Process process;
|
||||
try {
|
||||
process = await Process.start(_getFFmpegPath(), [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_delay_max', '10',
|
||||
'-rw_timeout', '30000000',
|
||||
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', targetUrl,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
||||
'-af', 'aresample=async=1',
|
||||
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
||||
'-muxdelay', '0', '-muxpreload', '0',
|
||||
'-f', 'mpegts', 'pipe:1',
|
||||
]);
|
||||
} on ProcessException catch (e) {
|
||||
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
||||
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré ($e)');
|
||||
return Response(503, body: 'FFmpeg start failed');
|
||||
}
|
||||
|
||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
print('[Live Turbo] $streamId ffmpeg: '
|
||||
'${LogRedactor.redactUrl(trimmed)}');
|
||||
}
|
||||
});
|
||||
|
||||
// Relayer stdout vers le client en gardant la contre-pression, et tuer
|
||||
// FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus
|
||||
// s'accumulent à chaque zap).
|
||||
final body = pipeWithBackpressure(
|
||||
process.stdout,
|
||||
onStop: () => process.kill(ProcessSignal.sigterm),
|
||||
);
|
||||
|
||||
return Response(
|
||||
200,
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Cache-Control': 'no-store',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Route: /api/live/{streamId}.ts (Direct Proxy for recordings/raw playback)
|
||||
router.get('/<streamId>.ts', (Request request, String streamId) async {
|
||||
final playlist = await getPlaylist(request);
|
||||
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 result = await _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
wantGpu: useNvidiaGpu && quality != 'source',
|
||||
buildArgs: (gpu) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (gpu) ...['-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',
|
||||
// Sonde réduite (10 Mo → 5 Mo) : sur un upstream lent, télécharger
|
||||
// 10 Mo avant la première image ajoutait plusieurs secondes au
|
||||
// démarrage de chaque film.
|
||||
'-analyzeduration', '2000000',
|
||||
'-probesize', '5000000',
|
||||
'-i', targetUrl,
|
||||
..._vodVideoArgs(quality, gpu),
|
||||
..._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 session = result.session;
|
||||
if (!result.ready || session == null) {
|
||||
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 {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
|
||||
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 start = parseRecordingStart(request.url.queryParameters['start']);
|
||||
final offsetKey = recordingOffsetKey(start);
|
||||
final sessionPrefix = 'rec_${streamId}_t';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
|
||||
// La capture est faite en `-c copy` : le fichier contient le codec
|
||||
// d'origine de la chaîne, presque toujours du H.264 — directement
|
||||
// lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p,
|
||||
// si bien que la lecture démarrait collée au front d'encodage et se
|
||||
// coupait à la moindre hésitation. En copie, la segmentation va à la
|
||||
// vitesse du disque : l'enregistrement devient navigable en quelques
|
||||
// secondes. Seuls les codecs que le navigateur ne sait pas lire
|
||||
// (HEVC, MPEG-2…) justifient encore un ré-encodage.
|
||||
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
||||
final canCopyVideo = videoCodec == 'h264';
|
||||
|
||||
List<String> buildArgs({required bool copyVideo, required bool gpu}) {
|
||||
final useNvidiaGpu = !copyVideo && gpu;
|
||||
return [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||
// -ss AVANT -i : recherche rapide par index, sans décoder ce qui
|
||||
// précède. La sortie repart de 0, le lecteur ré-ajoute l'offset.
|
||||
// En copie, la position atteinte est l'image-clé qui précède.
|
||||
if (start > 0) ...['-ss', '$start'],
|
||||
'-i', targetUrl,
|
||||
if (copyVideo) ...[
|
||||
'-c:v', 'copy',
|
||||
] else if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
|
||||
'-bufsize', '6000k',
|
||||
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
|
||||
] else ...[
|
||||
// veryfast/crf 20 au lieu de medium/crf 18 : à ce niveau le rendu
|
||||
// est visuellement identique, mais medium ne tenait pas le temps
|
||||
// réel en 1080p sur un CPU modeste → lecture d'enregistrement qui
|
||||
// démarre lentement puis bufferise.
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
||||
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
|
||||
'-g', '48', '-threads', '0',
|
||||
],
|
||||
..._audioArgs(withFilters: true),
|
||||
// `event` et non `vod` : en VOD, hls.js considère la playlist comme
|
||||
// définitive et ne la relit jamais. Il ne voyait donc que les
|
||||
// quelques secondes déjà transcodées à l'ouverture — d'où une durée
|
||||
// absurde et une barre de progression inutilisable. En `event`, la
|
||||
// playlist est rechargée et la zone navigable grandit avec l'encodage.
|
||||
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'event', '-hls_allow_cache', '1',
|
||||
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||
'playlist.m3u8',
|
||||
];
|
||||
}
|
||||
|
||||
/// Démarre (ou récupère) la session et attend qu'elle ait de l'avance.
|
||||
///
|
||||
/// Trois segments avant de rendre la main : c'est la marge qui manquait
|
||||
/// au démarrage, la playlist étant servie dès le premier segment. En
|
||||
/// copie vidéo elle est produite en une fraction de seconde, elle ne
|
||||
/// coûte donc que sur un ré-encodage.
|
||||
Future<_SessionAttempt> run(bool copyVideo) {
|
||||
if (!sessionManager.contains(sessionId)) {
|
||||
print(
|
||||
'[Recording] $sessionId : '
|
||||
'${copyVideo ? 'copie vidéo' : 'ré-encodage'} '
|
||||
'(codec source : ${videoCodec ?? 'inconnu'})',
|
||||
);
|
||||
}
|
||||
if (copyVideo) {
|
||||
return _runSession(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
argsBuilder: (_) => buildArgs(copyVideo: true, gpu: false),
|
||||
minSegments: 3,
|
||||
);
|
||||
}
|
||||
return _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(),
|
||||
buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu),
|
||||
minSegments: 3,
|
||||
);
|
||||
}
|
||||
|
||||
var attempt = await run(canCopyVideo);
|
||||
|
||||
// La copie a échoué : certains conteneurs ou horodatages ne s'y prêtent
|
||||
// pas. Plutôt qu'un écran d'erreur, on retombe sur le ré-encodage, qui
|
||||
// était le seul chemin jusqu'ici.
|
||||
if (!attempt.ready && canCopyVideo) {
|
||||
print(
|
||||
'[Recording] $sessionId : copie vidéo refusée (${attempt.error}) '
|
||||
'— repli sur le ré-encodage',
|
||||
);
|
||||
sessionManager.killSession(sessionId);
|
||||
attempt = await run(false);
|
||||
}
|
||||
|
||||
final session = attempt.session;
|
||||
if (!attempt.ready || session == null) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502,
|
||||
body: 'Recording transcoder failed: ${attempt.error}');
|
||||
}
|
||||
|
||||
session.touch();
|
||||
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
||||
// progression), une fois la nouvelle session confirmée démarrée.
|
||||
sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId);
|
||||
|
||||
// Les segments sont préfixés par l'offset pour rester rattachés à LEUR
|
||||
// session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à
|
||||
// 45 min sont deux fichiers différents.
|
||||
final playlist = await File('${session.dir.path}/playlist.m3u8')
|
||||
.readAsString();
|
||||
|
||||
return Response.ok(
|
||||
rewriteRecordingPlaylist(playlist, offsetKey),
|
||||
headers: _hlsHeaders(),
|
||||
);
|
||||
});
|
||||
|
||||
Response serveSegment(String streamId, String offsetKey, String segment) {
|
||||
if (!_isValidStreamId(streamId) ||
|
||||
parseRecordingOffsetKey(offsetKey) == null ||
|
||||
segment.contains('..') ||
|
||||
segment.contains('/')) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
sessionManager.touch(sessionId);
|
||||
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
|
||||
}
|
||||
|
||||
router.get('/<streamId>/<offsetKey>/<segment>',
|
||||
(Request request, String streamId, String offsetKey, String segment) {
|
||||
return serveSegment(streamId, offsetKey, segment);
|
||||
});
|
||||
|
||||
// Compat : playlist servie avant la mise en place des offsets, encore en
|
||||
// cache dans un onglet ouvert.
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) {
|
||||
return serveSegment(streamId, recordingOffsetKey(0), segment);
|
||||
});
|
||||
|
||||
return router.call;
|
||||
}
|
||||
@@ -1,180 +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 (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (db.findUserByUsername(username) != null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'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'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.updateUserPassword(id, password);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
} 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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
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);
|
||||
|
||||
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
|
||||
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
|
||||
// (donc des jetons valides), playlists et enregistrements orphelins.
|
||||
_db.execute('PRAGMA foreign_keys = ON');
|
||||
_db.execute('PRAGMA journal_mode = WAL');
|
||||
_db.execute('PRAGMA busy_timeout = 5000');
|
||||
|
||||
await _createTables();
|
||||
_runMigrations();
|
||||
print('Database initialized: $dbPath');
|
||||
}
|
||||
|
||||
/// Migrations de schéma pour les bases créées par une version antérieure.
|
||||
///
|
||||
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
|
||||
/// existante : chaque colonne introduite après coup doit avoir sa migration.
|
||||
/// Les migrations sont numérotées et rejouées uniquement si nécessaire.
|
||||
void _runMigrations() {
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
)
|
||||
''');
|
||||
|
||||
final result = _db.select(
|
||||
'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version',
|
||||
);
|
||||
var current = result.first['v'] as int;
|
||||
|
||||
final migrations = <int, void Function()>{
|
||||
// v1 : colonne error_reason absente des bases d'avant son introduction.
|
||||
1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'),
|
||||
// v2 : mode de correspondance des season passes. 'contains' pour les
|
||||
// lignes existantes (comportement historique) ; les nouvelles créations
|
||||
// passent par l'API qui choisit 'exact' par défaut.
|
||||
2: () => _addColumnIfMissing(
|
||||
'season_passes',
|
||||
'match_mode',
|
||||
"TEXT NOT NULL DEFAULT 'contains'",
|
||||
),
|
||||
};
|
||||
|
||||
for (final entry in migrations.entries) {
|
||||
if (entry.key <= current) continue;
|
||||
entry.value();
|
||||
_db.execute(
|
||||
'INSERT INTO schema_version (version) VALUES (?)',
|
||||
[entry.key],
|
||||
);
|
||||
current = entry.key;
|
||||
print('[DB] Migration v${entry.key} appliquée');
|
||||
}
|
||||
}
|
||||
|
||||
void _addColumnIfMissing(String table, String column, String definition) {
|
||||
final columns = _db.select('PRAGMA table_info($table)');
|
||||
final exists = columns.any((row) => row['name'] == column);
|
||||
if (!exists) {
|
||||
_db.execute('ALTER TABLE $table ADD COLUMN $column $definition');
|
||||
}
|
||||
}
|
||||
|
||||
/// Create database tables
|
||||
Future<void> _createTables() async {
|
||||
// Users table
|
||||
_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,
|
||||
match_mode TEXT NOT NULL DEFAULT 'exact',
|
||||
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)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed default admin user if no users exist.
|
||||
///
|
||||
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
||||
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
|
||||
/// couple admin/admin restait souvent en place sur les instances exposées.
|
||||
Future<void> seedAdmin() async {
|
||||
final result = _db.select('SELECT COUNT(*) as count FROM users');
|
||||
final count = result.first['count'] as int;
|
||||
|
||||
if (count == 0) {
|
||||
final adminId = _uuid.v4();
|
||||
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
|
||||
final generated = envPassword == null || envPassword.isEmpty;
|
||||
final password = generated ? _generatePassword() : envPassword;
|
||||
final passwordHash = PasswordHasher.hash(password);
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, 1)
|
||||
''',
|
||||
[adminId, 'admin', passwordHash],
|
||||
);
|
||||
|
||||
if (generated) {
|
||||
print('╔══════════════════════════════════════════════════════════╗');
|
||||
print(' Compte admin créé — mot de passe initial (affiché une');
|
||||
print(' seule fois, changez-le après la première connexion) :');
|
||||
print(' utilisateur: admin');
|
||||
print(' mot de passe: $password');
|
||||
print('╚══════════════════════════════════════════════════════════╝');
|
||||
} else {
|
||||
print(
|
||||
'Default admin user created (username: admin, '
|
||||
'password: ADMIN_INITIAL_PASSWORD)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _generatePassword({int length = 16}) {
|
||||
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
|
||||
// depuis les logs du conteneur.
|
||||
const chars =
|
||||
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
length,
|
||||
(_) => chars[random.nextInt(chars.length)],
|
||||
).join();
|
||||
}
|
||||
|
||||
// ==================== Users ====================
|
||||
|
||||
/// Find user by username
|
||||
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();
|
||||
|
||||
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du scheduler
|
||||
// et la déduplication des season passes reposent sur ce format unique.
|
||||
final startUtc = startTime.toUtc();
|
||||
final endUtc = endTime.toUtc();
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
[
|
||||
recordingId,
|
||||
userId,
|
||||
channelId,
|
||||
streamUrl,
|
||||
title,
|
||||
startUtc.toIso8601String(),
|
||||
endUtc.toIso8601String(),
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
return Recording(
|
||||
id: recordingId,
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startUtc,
|
||||
endTime: endUtc,
|
||||
status: 'scheduled',
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lister tous les enregistrements (pour l'admin)
|
||||
List<Recording> getAllRecordings() {
|
||||
final result =
|
||||
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
|
||||
/// Évite de désérialiser tout l'historique toutes les 10 secondes.
|
||||
List<Recording> getPendingRecordings() {
|
||||
final result = _db.select(
|
||||
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') "
|
||||
'ORDER BY start_time ASC',
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Enregistrements terminés (completed/failed/cancelled) du plus ancien au
|
||||
/// plus récent — utilisé par la rotation disque.
|
||||
List<Recording> getFinishedRecordingsOldestFirst() {
|
||||
final result = _db.select(
|
||||
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') "
|
||||
'ORDER BY start_time ASC',
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Lister les enregistrements d'un utilisateur spécifique
|
||||
List<Recording> getUserRecordings(String userId) {
|
||||
final result = _db.select(
|
||||
'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],
|
||||
);
|
||||
}
|
||||
|
||||
/// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé
|
||||
/// dans l'EPG depuis sa création par un season pass).
|
||||
void updateRecordingWindow(String id, DateTime start, DateTime end) {
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE tv_recordings
|
||||
SET start_time = ?, end_time = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[
|
||||
start.toUtc().toIso8601String(),
|
||||
end.toUtc().toIso8601String(),
|
||||
DateTime.now().toIso8601String(),
|
||||
id,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
|
||||
void deleteRecording(String id) {
|
||||
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// ==================== 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,
|
||||
String matchMode = 'exact',
|
||||
}) {
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
_db.execute(
|
||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, userId, showTitle, channelId, streamUrl, matchMode, now],
|
||||
);
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'show_title': showTitle,
|
||||
'channel_id': channelId,
|
||||
'stream_url': streamUrl,
|
||||
'match_mode': matchMode,
|
||||
'enabled': 1,
|
||||
'created_at': now,
|
||||
};
|
||||
}
|
||||
|
||||
/// Lister les Season Passes actifs (pour le scheduler)
|
||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
||||
);
|
||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
|
||||
List<Map<String, dynamic>> getSeasonPassesForUser(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId],
|
||||
);
|
||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
|
||||
Map<String, dynamic>? getSeasonPassById(String id) {
|
||||
final result =
|
||||
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
|
||||
if (result.isEmpty) return null;
|
||||
return Map<String, dynamic>.from(result.first);
|
||||
}
|
||||
|
||||
/// Supprimer un Season Pass
|
||||
void deleteSeasonPass(String id) {
|
||||
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/// Cherche un enregistrement existant pour cet épisode (déduplication des
|
||||
/// season passes) : même titre, début à ±[tolerance] près.
|
||||
///
|
||||
/// La comparaison par plage remplace l'ancienne égalité de chaîne, qui
|
||||
/// échouait dès que le format stocké différait (avec/sans 'Z') ou que le
|
||||
/// panneau décalait le programme de quelques secondes — l'épisode était
|
||||
/// alors réenregistré en double.
|
||||
Recording? findRecordingForEpisode(
|
||||
String title,
|
||||
DateTime startTime, {
|
||||
Duration tolerance = const Duration(minutes: 2),
|
||||
}) {
|
||||
final startUtc = startTime.toUtc();
|
||||
// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
|
||||
// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
|
||||
final result = _db.select(
|
||||
'''SELECT * FROM tv_recordings
|
||||
WHERE LOWER(title) = LOWER(?)
|
||||
AND start_time BETWEEN ? AND ?
|
||||
AND status NOT IN ('failed', 'cancelled')
|
||||
LIMIT 1''',
|
||||
[
|
||||
title,
|
||||
startUtc.subtract(tolerance).toIso8601String(),
|
||||
startUtc.add(tolerance).toIso8601String(),
|
||||
],
|
||||
);
|
||||
if (result.isEmpty) return null;
|
||||
return Recording.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
void close() {
|
||||
_db.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +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.
|
||||
/// Public : le proxy /api/xtream fait sa propre vérification de session
|
||||
/// (le contrôle doit rester DANS le handler, après le test de chemin,
|
||||
/// pour que les requêtes non-proxy tombent sur le handler statique).
|
||||
String? extractAuthToken(Request request) => _extractToken(request);
|
||||
|
||||
String? _extractToken(Request request) {
|
||||
// Try Authorization header first
|
||||
final authHeader = request.headers['authorization'];
|
||||
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;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Security Middleware Collection
|
||||
///
|
||||
/// Includes:
|
||||
/// - Redacted request logging
|
||||
/// - Honeypot Routes (Trap for bots)
|
||||
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
|
||||
/// - Rate Limiting (Basic DoS protection)
|
||||
/// - Login-specific rate limiting (brute-force protection)
|
||||
|
||||
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
|
||||
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
|
||||
/// docker-compose parle depuis le réseau Docker). Surcharger avec
|
||||
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
|
||||
final List<String> _trustedProxies =
|
||||
(Platform.environment['TRUSTED_PROXIES'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
bool _isTrustedProxy(String address) {
|
||||
if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address);
|
||||
final ip = InternetAddress.tryParse(address);
|
||||
if (ip == null) return false;
|
||||
if (ip.isLoopback) return true;
|
||||
if (ip.type == InternetAddressType.IPv4) {
|
||||
final parts = ip.address.split('.').map(int.parse).toList();
|
||||
if (parts[0] == 10) return true;
|
||||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] == 192 && parts[1] == 168) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Resolve the real client IP.
|
||||
///
|
||||
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
|
||||
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
|
||||
/// contourner le rate limit global comme la limite de tentatives de login.
|
||||
String clientIpOf(Request request) {
|
||||
final connectionInfo =
|
||||
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
||||
final socketAddress = connectionInfo?.remoteAddress.address;
|
||||
|
||||
final forwarded = request.headers['x-forwarded-for'];
|
||||
if (forwarded != null &&
|
||||
forwarded.isNotEmpty &&
|
||||
socketAddress != null &&
|
||||
_isTrustedProxy(socketAddress)) {
|
||||
return forwarded.split(',').first.trim();
|
||||
}
|
||||
return socketAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
/// 0. Redacted request logging.
|
||||
///
|
||||
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
|
||||
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
|
||||
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
|
||||
Middleware redactedLogRequests() {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
final watch = Stopwatch()..start();
|
||||
try {
|
||||
final response = await handler(request);
|
||||
watch.stop();
|
||||
final query =
|
||||
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ${response.statusCode} '
|
||||
'${request.method} '
|
||||
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
|
||||
'(${watch.elapsedMilliseconds}ms)',
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
watch.stop();
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ERR ${request.method} '
|
||||
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 1. Security Headers Middleware
|
||||
/// 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) {
|
||||
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
|
||||
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
|
||||
// contenant « console », « env » ou « wpadmin » n'importe où — y
|
||||
// compris des URLs proxifiées parfaitement légitimes.
|
||||
final path = '/${request.url.path}';
|
||||
|
||||
for (final trap in honeypotPaths) {
|
||||
if (path == trap || path.startsWith('$trap/')) {
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "105.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.1.0"
|
||||
args:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bcrypt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bcrypt
|
||||
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive
|
||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_methods:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_methods
|
||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
shelf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_router
|
||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
shelf_static:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.14"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.31.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.19"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
xml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
@@ -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
|
||||
-443
@@ -1,443 +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 'api/logo_api.dart';
|
||||
import 'services/xmltv_epg_service.dart';
|
||||
import 'services/logo_catalog.dart';
|
||||
import 'api/season_passes_api.dart';
|
||||
import 'api/xtream_api_handler.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
import 'middleware/security_middleware.dart';
|
||||
import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
|
||||
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
|
||||
// (les Season Passes résolvent la playlist de leur propriétaire à chaque
|
||||
// scan : plus d'injection figée du premier utilisateur au démarrage)
|
||||
final recordingScheduler = RecordingScheduler(db);
|
||||
recordingScheduler.start();
|
||||
|
||||
// Initialize Streaming Subsystem
|
||||
await initStreaming();
|
||||
|
||||
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
|
||||
// enregistrements (kill FFmpeg, fusion des parties, statut en base), puis
|
||||
// les sessions de streaming, puis sortir. Sans cela les enregistrements
|
||||
// restaient au statut « recording » et la reprise d'orphelins devait
|
||||
// systématiquement rattraper au redémarrage.
|
||||
var shuttingDown = false;
|
||||
Future<void> shutdownServer(String signal) async {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
print('[Server] $signal reçu, arrêt en cours…');
|
||||
try {
|
||||
await recordingScheduler.shutdown();
|
||||
} catch (e) {
|
||||
print('[Server] Erreur à l\'arrêt du scheduler: $e');
|
||||
}
|
||||
sessionManager.killAll();
|
||||
db.close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
ProcessSignal.sigterm.watch().listen((_) => shutdownServer('SIGTERM'));
|
||||
ProcessSignal.sigint.watch().listen((_) => shutdownServer('SIGINT'));
|
||||
|
||||
// Helper to get playlist from request
|
||||
Future<PlaylistConfig?> getPlaylist(Request request) async {
|
||||
Playlist? playlist;
|
||||
|
||||
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, db);
|
||||
// Logos de chaînes : URL du panneau d'abord, puis repli par nom sur le
|
||||
// dépôt public tv-logos quand l'hébergeur de picons du revendeur tombe.
|
||||
final logoApi = LogoApi(proxyHandler.handler, LogoCatalog());
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||
// l'EPG se limite alors au panneau de l'abonné.
|
||||
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
|
||||
.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),
|
||||
)
|
||||
// Logos de chaînes avec repli (auth required ; le cookie de session
|
||||
// accompagne les requêtes d'image du navigateur)
|
||||
..get(
|
||||
'/api/logo',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
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
|
||||
// Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les
|
||||
// temporaires de la VM Dart et le dossier des sessions HLS — les fichiers
|
||||
// de plus de 24 h y étaient supprimés aveuglément.
|
||||
final cleanupService = CleanupService();
|
||||
cleanupService.addTarget(Directory('/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);
|
||||
}),
|
||||
);
|
||||
|
||||
// Les libs de lecture sont servies depuis l'image, plus depuis un CDN : si
|
||||
// elles manquent (build web incomplet, copie Docker partielle), le lecteur
|
||||
// échoue côté navigateur avec un simple « Échec du chargement » sans que
|
||||
// rien ne l'ait signalé au démarrage. On le dit ici, une fois, clairement.
|
||||
for (final lib in const ['hls.min.js', 'mpegts.min.js']) {
|
||||
final file = File('$webPath/vendor/$lib');
|
||||
if (!file.existsSync()) {
|
||||
print(
|
||||
'[Server] ATTENTION: web/vendor/$lib absent de $webPath — '
|
||||
'la lecture échouera dans le navigateur.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create static handler
|
||||
final baseStaticHandler = createStaticHandler(
|
||||
webPath,
|
||||
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.endsWith('.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
|
||||
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
|
||||
// contient username/password Xtream en clair.
|
||||
final pipeline = const Pipeline()
|
||||
.addMiddleware(redactedLogRequests())
|
||||
.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);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,284 +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());
|
||||
|
||||
// L'arrêt propre (docker stop / Ctrl+C) est orchestré par server.dart :
|
||||
// il clôture d'abord les enregistrements puis appelle killAll(). Un
|
||||
// handler local qui ferait exit(0) immédiatement court-circuiterait
|
||||
// cette clôture.
|
||||
}
|
||||
|
||||
FfmpegSession? get(String id) => _sessions[id];
|
||||
|
||||
/// 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 [minSegments]
|
||||
/// segments. Fails fast when the process dies before producing output,
|
||||
/// returning the recent stderr for diagnostics.
|
||||
///
|
||||
/// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la
|
||||
/// playlist dès le premier segment le fait partir avec 4 s de marge sur un
|
||||
/// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La
|
||||
/// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel
|
||||
/// que soit son nombre de segments : rien de plus n'arrivera.
|
||||
Future<({bool ready, String? error})> waitForPlaylist(
|
||||
FfmpegSession session, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (playlistFile.existsSync()) {
|
||||
final content = playlistFile.readAsStringSync();
|
||||
final segments = '.ts'.allMatches(content).length;
|
||||
if (segments >= minSegments ||
|
||||
(segments > 0 && content.contains('#EXT-X-ENDLIST'))) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
}
|
||||
if (session.exited) {
|
||||
// Un encodeur qui s'est terminé proprement en ayant produit moins de
|
||||
// segments que demandé a simplement fini : c'est un succès.
|
||||
if (session.exitCode == 0 &&
|
||||
playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (
|
||||
ready: false,
|
||||
error: 'FFmpeg exited (${session.exitCode}): '
|
||||
'${session.recentStderr.join().trim()}'
|
||||
);
|
||||
}
|
||||
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde de
|
||||
// latence pure entre la disponibilité de la playlist et sa réponse.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
// Le délai est écoulé : mieux vaut servir ce qui existe que rien.
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (ready: false, error: 'Timeout waiting for transcoder');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Tue les sessions frères d'un même contenu (`rec_<id>_t0`, `rec_<id>_t600`…)
|
||||
/// que plus personne ne consomme depuis [idleFor].
|
||||
///
|
||||
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
|
||||
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
|
||||
/// barre de progression laisserait autant de processus vivants jusqu'au
|
||||
/// TTL de 15 minutes. Le délai d'inactivité protège un second spectateur
|
||||
/// du même enregistrement : sa session est « touchée » à chaque segment.
|
||||
void killIdleSiblings(
|
||||
String prefix, {
|
||||
required String keep,
|
||||
Duration idleFor = const Duration(seconds: 30),
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (final session in _sessions.values.toList()) {
|
||||
if (session.id == keep || !session.id.startsWith(prefix)) continue;
|
||||
if (now.difference(session.lastAccess) < idleFor) continue;
|
||||
print('[FFmpegManager] Dropping stale sibling ${session.id}');
|
||||
killSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
void killAll() {
|
||||
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
|
||||
for (final id in _sessions.keys.toList()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/// Repli GPU → CPU des transcodages.
|
||||
///
|
||||
/// Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
|
||||
/// sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
|
||||
/// FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
|
||||
/// repli, le lecteur recevait un 502, relançait… et retombait sur le même
|
||||
/// GPU saturé : le flux restait illisible tant que la carte était pleine.
|
||||
|
||||
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
|
||||
///
|
||||
/// N'est consulté que pour une session lancée sur le GPU : « opening
|
||||
/// encoder » y désigne donc forcément h264_nvenc.
|
||||
final _gpuFailurePattern = RegExp(
|
||||
r'nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|'
|
||||
r'out of memory|Device creation failed|hwaccel|opening encoder',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
|
||||
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
|
||||
/// réglerait pas, et qui doublerait inutilement la charge d'un serveur déjà
|
||||
/// occupé.
|
||||
bool isGpuFailure(String? error) =>
|
||||
error != null && _gpuFailurePattern.hasMatch(error);
|
||||
|
||||
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
|
||||
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
|
||||
/// leur repli.
|
||||
class GpuHealth {
|
||||
final Duration cooldown;
|
||||
final DateTime Function() _now;
|
||||
DateTime? _unavailableUntil;
|
||||
|
||||
GpuHealth({
|
||||
this.cooldown = const Duration(minutes: 2),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
bool get available =>
|
||||
_unavailableUntil == null || !_now().isBefore(_unavailableUntil!);
|
||||
|
||||
void markFailed() => _unavailableUntil = _now().add(cooldown);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
|
||||
///
|
||||
/// POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
|
||||
/// revendeur. Quand cette machine tombe — cas observé : serveur de picons en
|
||||
/// maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
|
||||
/// Le dépôt `tv-logos` est maintenu, nommé de façon régulière
|
||||
/// (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
|
||||
class LogoCatalog {
|
||||
LogoCatalog({
|
||||
http.Client? client,
|
||||
this.listingTtl = const Duration(hours: 24),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
|
||||
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
|
||||
final Duration listingTtl;
|
||||
|
||||
/// Recul après un échec de téléchargement de liste (limite de débit,
|
||||
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
|
||||
final Duration retryBackoff;
|
||||
|
||||
final Map<String, _Listing> _listings = {};
|
||||
final Map<String, Future<_Listing?>> _listingsInFlight = {};
|
||||
final Map<String, DateTime> _listingFailedAt = {};
|
||||
final Map<String, List<int>> _bytes = {};
|
||||
|
||||
/// Octets PNG du logo correspondant à [channelName], ou `null`.
|
||||
Future<List<int>?> logoFor(String channelName) async {
|
||||
final country = countryFolderFor(channelName);
|
||||
final listing = await _listingFor(country);
|
||||
if (listing == null) return null;
|
||||
|
||||
final key = matchLogoKey(channelName, listing.urls.keys);
|
||||
if (key == null) return null;
|
||||
|
||||
final url = listing.urls[key]!;
|
||||
final cached = _bytes[url];
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final response = await _client
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) return null;
|
||||
return _bytes[url] = response.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Listing?> _listingFor(String country) async {
|
||||
final cached = _listings[country];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.fetchedAt) < listingTtl) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final failedAt = _listingFailedAt[country];
|
||||
if (failedAt != null &&
|
||||
DateTime.now().difference(failedAt) < retryBackoff) {
|
||||
// Liste périmée plutôt que rien : les logos ne changent pas d'un jour
|
||||
// à l'autre.
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Les tuiles d'une grille arrivent en rafale : une seule requête de liste.
|
||||
return _listingsInFlight[country] ??=
|
||||
_fetchListing(country).whenComplete(() {
|
||||
_listingsInFlight.remove(country);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_Listing?> _fetchListing(String country) async {
|
||||
final url = 'https://api.github.com/repos/tv-logo/tv-logos/contents/'
|
||||
'countries/$country';
|
||||
try {
|
||||
final response = await _client.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'},
|
||||
).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
final urls = <String, String>{};
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is List) {
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map) continue;
|
||||
final name = entry['name']?.toString() ?? '';
|
||||
final download = entry['download_url']?.toString();
|
||||
if (download == null || !name.endsWith('.png')) continue;
|
||||
urls[_keyFromFileName(name)] = download;
|
||||
}
|
||||
}
|
||||
|
||||
final listing = _Listing(urls, DateTime.now());
|
||||
_listings[country] = listing;
|
||||
_listingFailedAt.remove(country);
|
||||
print('[LogoCatalog] $country : ${urls.length} logos');
|
||||
return listing;
|
||||
} catch (e) {
|
||||
_listingFailedAt[country] = DateTime.now();
|
||||
print('[LogoCatalog] liste $country indisponible : $e');
|
||||
return _listings[country];
|
||||
}
|
||||
}
|
||||
|
||||
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
|
||||
static String _keyFromFileName(String name) {
|
||||
final base = name.substring(0, name.length - '.png'.length);
|
||||
return base.replaceFirst(RegExp(r'-[a-z]{2}$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
class _Listing {
|
||||
_Listing(this.urls, this.fetchedAt);
|
||||
|
||||
/// Clé normalisée → URL de téléchargement brute.
|
||||
final Map<String, String> urls;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
|
||||
const _countryFolders = {
|
||||
'fr': 'france',
|
||||
'be': 'belgium',
|
||||
'ch': 'switzerland',
|
||||
'uk': 'united-kingdom',
|
||||
'gb': 'united-kingdom',
|
||||
'us': 'united-states',
|
||||
'ca': 'canada',
|
||||
'de': 'germany',
|
||||
'es': 'spain',
|
||||
'it': 'italy',
|
||||
'pt': 'portugal',
|
||||
'nl': 'netherlands',
|
||||
};
|
||||
|
||||
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
|
||||
final _countryPrefix = RegExp(r'^\W*([A-Za-z]{2})[\s\-:|]+');
|
||||
|
||||
/// Dossier `tv-logos` à consulter pour [channelName]. France par défaut :
|
||||
/// c'est l'audience de l'application.
|
||||
String countryFolderFor(String channelName) {
|
||||
final match = _countryPrefix.firstMatch(channelName);
|
||||
final code = match?.group(1)?.toLowerCase();
|
||||
return _countryFolders[code] ?? 'france';
|
||||
}
|
||||
|
||||
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
|
||||
/// de flux. Ils sont propres au panneau et absents des noms de logos.
|
||||
const _noise = {
|
||||
'fhd', 'uhd', 'hd', 'sd', '4k', '8k', 'hdr', 'hevc', 'h264', 'h265',
|
||||
'1080p', '720p', '50fps', 'fps', 'raw', 'backup', 'vip', 'multi',
|
||||
'vf', 'vo', 'vostfr',
|
||||
};
|
||||
|
||||
const _accents = {
|
||||
'à': 'a', 'á': 'a', 'â': 'a', 'ä': 'a', 'è': 'e', 'é': 'e', 'ê': 'e',
|
||||
'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ò': 'o', 'ó': 'o',
|
||||
'ô': 'o', 'ö': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c',
|
||||
};
|
||||
|
||||
List<String> _tokens(String channelName) {
|
||||
var name = channelName;
|
||||
final prefix = _countryPrefix.firstMatch(name);
|
||||
if (prefix != null &&
|
||||
_countryFolders.containsKey(prefix.group(1)!.toLowerCase())) {
|
||||
name = name.substring(prefix.end);
|
||||
}
|
||||
|
||||
final folded = StringBuffer();
|
||||
for (final rune in name.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
|
||||
return folded
|
||||
.toString()
|
||||
.replaceAll('+', ' plus ')
|
||||
.replaceAll('&', ' and ')
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty && !_noise.contains(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Clé de logo la plus proche de [channelName] parmi [available], ou `null`.
|
||||
///
|
||||
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
|
||||
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
|
||||
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
|
||||
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
|
||||
/// France 24.
|
||||
String? matchLogoKey(String channelName, Iterable<String> available) {
|
||||
final keys = available.toSet();
|
||||
final tokens = _tokens(channelName);
|
||||
if (tokens.isEmpty) return null;
|
||||
|
||||
List<String> withoutLive(List<String> parts) =>
|
||||
parts.where((t) => t != 'live').toList();
|
||||
|
||||
String? exact(List<String> parts) {
|
||||
final slug = parts.join('-');
|
||||
return parts.isNotEmpty && keys.contains(slug) ? slug : null;
|
||||
}
|
||||
|
||||
String? longerVariant(List<String> parts) {
|
||||
if (parts.length < 2) return null;
|
||||
final slug = parts.join('-');
|
||||
final longer = keys.where((k) => k.startsWith('$slug-')).toList()
|
||||
..sort((a, b) {
|
||||
final byLength = a.length.compareTo(b.length);
|
||||
return byLength != 0 ? byLength : a.compareTo(b);
|
||||
});
|
||||
return longer.isEmpty ? null : longer.first;
|
||||
}
|
||||
|
||||
final noLive = withoutLive(tokens);
|
||||
final direct = exact(tokens) ??
|
||||
exact(noLive) ??
|
||||
longerVariant(tokens) ??
|
||||
longerVariant(noLive);
|
||||
if (direct != null) return direct;
|
||||
|
||||
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
|
||||
if (RegExp(r'^\d+$').hasMatch(tokens.last)) {
|
||||
final brand = withoutLive(tokens.sublist(0, tokens.length - 1));
|
||||
if (brand.length >= 2) return exact(brand) ?? longerVariant(brand);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,962 +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;
|
||||
|
||||
/// Espace libre minimal (Mo) exigé pour démarrer une capture.
|
||||
final int minFreeDiskMb = int.tryParse(
|
||||
Platform.environment['MIN_FREE_DISK_MB'] ?? '',
|
||||
) ??
|
||||
500;
|
||||
|
||||
/// Quota du dossier d'enregistrements en Go (0 = rotation désactivée).
|
||||
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait
|
||||
/// aveuglément, y compris des enregistrements en cours d'écriture.
|
||||
final int recordingsQuotaGb = int.tryParse(
|
||||
Platform.environment['RECORDINGS_QUOTA_GB'] ?? '',
|
||||
) ??
|
||||
0;
|
||||
|
||||
/// Nombre maximal d'enregistrements créés par season pass et par scan.
|
||||
final int seasonPassMaxPerScan = int.tryParse(
|
||||
Platform.environment['SEASON_PASS_MAX_PER_SCAN'] ?? '',
|
||||
) ??
|
||||
10;
|
||||
|
||||
RecordingScheduler(this._db);
|
||||
|
||||
/// Un enregistrement est-il activement capturé par un processus FFmpeg ?
|
||||
bool isCapturing(String id) => _active.containsKey(id);
|
||||
|
||||
/// Nombre de relances FFmpeg de l'enregistrement actif [id] (null si inactif).
|
||||
int? retryCountOf(String id) => _active[id]?.consecutiveFailures;
|
||||
|
||||
void start() {
|
||||
print(
|
||||
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
|
||||
'(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é');
|
||||
}
|
||||
|
||||
/// Arrêt gracieux pour un `docker stop` : arrête les timers, clôt chaque
|
||||
/// enregistrement actif (kill FFmpeg, fusion des parties, statut en base)
|
||||
/// et attend la fin des clôtures dans la limite de [timeout].
|
||||
///
|
||||
/// Sans cette attente, le conteneur meurt avant la clôture : les
|
||||
/// enregistrements restent au statut « recording » et la reprise d'orphelins
|
||||
/// doit systématiquement rattraper au redémarrage.
|
||||
Future<void> shutdown({Duration timeout = const Duration(seconds: 8)}) async {
|
||||
_timer?.cancel();
|
||||
_seasonPassTimer?.cancel();
|
||||
final closings = <Future<void>>[];
|
||||
for (final id in _active.keys.toList()) {
|
||||
final future =
|
||||
_stopActiveRecording(id, reason: 'Arrêt du serveur');
|
||||
if (future != null) closings.add(future);
|
||||
}
|
||||
if (closings.isNotEmpty) {
|
||||
print(
|
||||
'[RecordingScheduler] Clôture de ${closings.length} enregistrement(s)…',
|
||||
);
|
||||
await Future.wait(closings).timeout(timeout, onTimeout: () => const []);
|
||||
}
|
||||
print('[RecordingScheduler] Arrêté proprement');
|
||||
}
|
||||
|
||||
Future<void> _checkAndRunRecordings() async {
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
|
||||
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.getPendingRecordings();
|
||||
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise un titre pour la correspondance : minuscules, espaces réduits.
|
||||
static String _normalizeTitle(String title) =>
|
||||
title.toLowerCase().trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
/// Le titre EPG [title] correspond-il au pass selon son [matchMode] ?
|
||||
static bool _titleMatches(String title, String showTitle, String matchMode) {
|
||||
final t = _normalizeTitle(title);
|
||||
final s = _normalizeTitle(showTitle);
|
||||
if (s.isEmpty) return false;
|
||||
return matchMode == 'contains' ? t.contains(s) : t == s;
|
||||
}
|
||||
|
||||
Future<void> _checkSeasonPasses() async {
|
||||
final passes = _db.getAllSeasonPasses();
|
||||
if (passes.isEmpty) return;
|
||||
|
||||
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;
|
||||
final matchMode = pass['match_mode'] as String? ?? 'contains';
|
||||
|
||||
// Résoudre la playlist du PROPRIÉTAIRE du pass au moment du scan.
|
||||
// L'ancienne config injectée au démarrage venait du premier utilisateur
|
||||
// de la base et n'était jamais réactualisée : une playlist ajoutée
|
||||
// après coup rendait les passes muets, et en multi-utilisateurs les
|
||||
// credentials de l'un servaient aux passes d'un autre.
|
||||
final playlists = _db.getPlaylists(userId);
|
||||
if (playlists.isEmpty) {
|
||||
print(
|
||||
'[SeasonPass] Aucune playlist pour le propriétaire du pass '
|
||||
'"$showTitle", scan ignoré',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final playlist = playlists.first;
|
||||
|
||||
// Récupérer l'EPG de la chaîne (les prochains programmes ; `limit`
|
||||
// est un nombre de programmes, pas des heures)
|
||||
final url =
|
||||
'${playlist.serverUrl}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||
|
||||
final response =
|
||||
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>? ?? [];
|
||||
|
||||
var createdThisScan = 0;
|
||||
for (final item in listings) {
|
||||
String title = item['title'] as String? ?? '';
|
||||
try {
|
||||
title = utf8.decode(base64Decode(title));
|
||||
} catch (_) {}
|
||||
|
||||
if (!_titleMatches(title, showTitle, matchMode)) 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 : cet épisode est-il déjà planifié/enregistré ?
|
||||
final existing = _db.findRecordingForEpisode(title, startTime);
|
||||
if (existing != null) {
|
||||
// Programme déplacé dans l'EPG depuis la planification :
|
||||
// réaligner la fenêtre tant que la capture n'a pas commencé.
|
||||
if (existing.status == 'scheduled' &&
|
||||
(existing.startTime.toUtc() != startTime ||
|
||||
existing.endTime.toUtc() != endTime)) {
|
||||
_db.updateRecordingWindow(existing.id, startTime, endTime);
|
||||
print(
|
||||
'[SeasonPass] "$title" réaligné sur le nouvel horaire '
|
||||
'${startTime.toLocal()}',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (createdThisScan >= seasonPassMaxPerScan) {
|
||||
print(
|
||||
'[SeasonPass] Plafond de $seasonPassMaxPerScan créations atteint '
|
||||
'pour "$showTitle" sur ce scan',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Créer l'enregistrement automatiquement
|
||||
_db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
createdThisScan++;
|
||||
print(
|
||||
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ne jamais imprimer l'exception brute : une ClientException peut
|
||||
// contenir l'URL amont avec les credentials Xtream.
|
||||
print(
|
||||
'[SeasonPass] Erreur pour le pass "${pass['show_title']}": '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Refuser de démarrer sur un volume plein : mieux vaut un échec
|
||||
// explicite immédiat qu'une capture qui meurt à mi-parcours.
|
||||
final freeBytes = await _freeDiskBytes(recordingsDir.path);
|
||||
if (freeBytes != null && freeBytes < minFreeDiskMb * 1024 * 1024) {
|
||||
final freeMb = freeBytes ~/ (1024 * 1024);
|
||||
print(
|
||||
'[RecordingScheduler] Espace disque insuffisant ($freeMb Mo libres, '
|
||||
'minimum $minFreeDiskMb Mo) : "${recording.title}" refusé',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
recording.id,
|
||||
'failed',
|
||||
errorReason:
|
||||
'Espace disque insuffisant ($freeMb Mo libres, minimum $minFreeDiskMb Mo)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final filePath = p.join(recordingsDir.path, _fileNameFor(recording));
|
||||
final logPath = p.setExtension(filePath, '.log');
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Arrête l'enregistrement actif [id] et retourne la future de clôture
|
||||
/// (fusion des parties + statut), ou null si aucun n'est actif.
|
||||
Future<void>? _stopActiveRecording(String id, {String? reason}) {
|
||||
final active = _active.remove(id);
|
||||
if (active == null) return null;
|
||||
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');
|
||||
final closing = _finalizeStopped(active);
|
||||
unawaited(closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
/// 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. Le fragment d'id garantit
|
||||
// l'unicité : deux utilisateurs enregistrant le même programme sur la même
|
||||
// chaîne s'écrasaient mutuellement (FFmpeg est lancé avec -y).
|
||||
final safeTitle =
|
||||
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||
final dateStr = recording.startTime
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '')
|
||||
.split('.')[0];
|
||||
final idFragment = recording.id.length >= 8
|
||||
? recording.id.substring(0, 8)
|
||||
: recording.id;
|
||||
return '${safeTitle}_${dateStr}_$idFragment.mkv';
|
||||
}
|
||||
|
||||
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
/// Espace libre (octets) sur le volume qui porte [path], ou null si `df`
|
||||
/// n'est pas disponible.
|
||||
Future<int?> _freeDiskBytes(String path) async {
|
||||
try {
|
||||
final result = await Process.run('df', ['-B1', '--output=avail', path]);
|
||||
if (result.exitCode != 0) return null;
|
||||
final lines = (result.stdout as String).trim().split('\n');
|
||||
return int.tryParse(lines.last.trim());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fichiers sur disque associés à un enregistrement : fichier principal,
|
||||
/// log, et parties issues des relances.
|
||||
List<String> filesFor(String filePath) {
|
||||
final paths = <String>[filePath, p.setExtension(filePath, '.log')];
|
||||
for (var attempt = 1;; attempt++) {
|
||||
final part = _partPath(filePath, attempt);
|
||||
if (!File(part).existsSync()) break;
|
||||
paths.add(part);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// Supprime les fichiers d'un enregistrement (appelé par l'API à la
|
||||
/// suppression, et par la rotation disque).
|
||||
Future<void> deleteRecordingFiles(String filePath) async {
|
||||
for (final path in filesFor(filePath)) {
|
||||
await _deleteQuietly(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation par quota d'octets (env RECORDINGS_QUOTA_GB, 0 = désactivée).
|
||||
///
|
||||
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait les plus
|
||||
/// anciens fichiers du dossier sans distinction : elle pouvait effacer une
|
||||
/// partie en cours d'écriture par FFmpeg et laissait en base des lignes
|
||||
/// pointant vers des fichiers disparus. Ici on ne supprime que des
|
||||
/// enregistrements TERMINÉS connus de la base, du plus ancien au plus
|
||||
/// récent, fichiers et ligne BDD ensemble, jamais un enregistrement actif.
|
||||
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
||||
if (recordingsQuotaGb <= 0) return;
|
||||
try {
|
||||
final quotaBytes = recordingsQuotaGb * 1024 * 1024 * 1024;
|
||||
var totalBytes = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
totalBytes += await entity.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (totalBytes <= quotaBytes) return;
|
||||
|
||||
print(
|
||||
'[RecordingScheduler] Quota disque dépassé '
|
||||
'(${totalBytes ~/ (1024 * 1024)} Mo > $recordingsQuotaGb Go) : '
|
||||
'rotation des enregistrements terminés les plus anciens',
|
||||
);
|
||||
|
||||
for (final old in _db.getFinishedRecordingsOldestFirst()) {
|
||||
if (totalBytes <= quotaBytes) break;
|
||||
final path = old.filePath;
|
||||
if (path == null) continue;
|
||||
// Jamais un enregistrement encore capturé (statut périmé en base).
|
||||
if (_active.containsKey(old.id)) continue;
|
||||
var freed = 0;
|
||||
for (final f in filesFor(path)) {
|
||||
try {
|
||||
freed += File(f).existsSync() ? File(f).lengthSync() : 0;
|
||||
} catch (_) {}
|
||||
}
|
||||
await deleteRecordingFiles(path);
|
||||
_db.deleteRecording(old.id);
|
||||
totalBytes -= freed;
|
||||
print(
|
||||
'[RecordingScheduler] Rotation : "${old.title}" supprimé '
|
||||
'(${freed ~/ (1024 * 1024)} Mo libérés)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print(
|
||||
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -1,340 +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),
|
||||
this.horizon = const Duration(hours: 48),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
this.downloadTimeout = const Duration(minutes: 5),
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Les programmes qui commencent au-delà de cet horizon sont écartés à
|
||||
/// l'indexation.
|
||||
///
|
||||
/// Un dump national couvre souvent sept jours pour un millier de chaînes ;
|
||||
/// tout garder en mémoire dans le conteneur coûte des centaines de
|
||||
/// mégaoctets pour un guide qui n'affiche que le programme courant et les
|
||||
/// suivants.
|
||||
final Duration horizon;
|
||||
|
||||
/// Délai minimal entre deux tentatives quand aucune source n'a répondu.
|
||||
///
|
||||
/// Sans ce recul, une source morte était retéléchargée à chaque consultation
|
||||
/// du guide — une requête sortante par chaîne affichée, soit exactement le
|
||||
/// flot qu'un index est censé éviter.
|
||||
final Duration retryBackoff;
|
||||
|
||||
/// Plafond de téléchargement d'un dump. Le guide est facultatif : mieux vaut
|
||||
/// abandonner et servir le panneau que faire attendre l'utilisateur.
|
||||
final Duration downloadTimeout;
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// clé de chaîne normalisée → programmes triés par heure de début.
|
||||
Map<String, List<XmltvProgramme>> _index = {};
|
||||
DateTime? _indexedAt;
|
||||
DateTime? _failedAt;
|
||||
Future<void>? _refreshInFlight;
|
||||
|
||||
bool get hasData => _index.isNotEmpty;
|
||||
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();
|
||||
|
||||
final sinceFailure = _failedAt == null
|
||||
? null
|
||||
: DateTime.now().difference(_failedAt!);
|
||||
if (sinceFailure != null && sinceFailure < retryBackoff) {
|
||||
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.
|
||||
_failedAt = DateTime.now();
|
||||
print('[XmltvEpg] aucune source disponible, index précédent conservé');
|
||||
return;
|
||||
}
|
||||
|
||||
_index = merged;
|
||||
_indexedAt = DateTime.now();
|
||||
_failedAt = null;
|
||||
print('[XmltvEpg] index prêt : ${merged.length} chaînes');
|
||||
}
|
||||
|
||||
Future<String> _download(String url) async {
|
||||
final response =
|
||||
await _client.get(Uri.parse(url)).timeout(downloadTimeout);
|
||||
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 limit = DateTime.now().toUtc().add(horizon);
|
||||
final byChannel = <String, List<XmltvProgramme>>{};
|
||||
|
||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
|
||||
// <display-name> différent de l'identifiant. On indexe les deux pour
|
||||
// maximiser les correspondances.
|
||||
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) &&
|
||||
start.isBefore(limit)) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/asset_failure_cache.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFailureCache', () {
|
||||
test('un hôte inconnu est considéré joignable', () {
|
||||
final cache = AssetFailureCache();
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('un échec isolé ne coupe pas l’hôte', () {
|
||||
// Un 5xx passager ne doit pas priver l'utilisateur de ses logos
|
||||
// pendant plusieurs minutes.
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('coupe l’hôte après le seuil d’échecs consécutifs', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
cache.recordFailure('cdn.example');
|
||||
}
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
});
|
||||
|
||||
test('un succès remet le compteur à zéro', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordSuccess('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('la coupure expire après le TTL', () {
|
||||
var now = DateTime(2026, 9, 19, 9);
|
||||
final cache = AssetFailureCache(
|
||||
threshold: 1,
|
||||
ttl: const Duration(minutes: 5),
|
||||
clock: () => now,
|
||||
);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
|
||||
now = now.add(const Duration(minutes: 6));
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('les hôtes sont indépendants', () {
|
||||
final cache = AssetFailureCache(threshold: 1);
|
||||
cache.recordFailure('mort.example');
|
||||
expect(cache.isDown('mort.example'), isTrue);
|
||||
expect(cache.isDown('vivant.example'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/epg_api.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) :
|
||||
/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne
|
||||
/// 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);
|
||||
});
|
||||
});
|
||||
|
||||
group('handleGetEpg', () {
|
||||
final playlist = PlaylistConfig(
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
dns: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
String xmltvDump(DateTime start, DateTime stop) {
|
||||
String stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
return '''
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<tv>
|
||||
<channel id="France2.fr"><display-name>FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
Request requestFor(String channelId) =>
|
||||
Request('GET', Uri.parse('http://localhost/api/epg/$channelId'));
|
||||
|
||||
test('sert le guide depuis le dump XMLTV du panneau, sans appel par '
|
||||
'chaîne', () async {
|
||||
// Un appel `player_api` par chaîne coûte plusieurs secondes chez le
|
||||
// fournisseur : le dump du panneau couvre toutes les chaînes d'un coup.
|
||||
final now = DateTime.now().toUtc();
|
||||
final actions = <String>[];
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response(
|
||||
xmltvDump(now.subtract(const Duration(minutes: 5)),
|
||||
now.add(const Duration(minutes: 25))),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'stream_id': 845452,
|
||||
'name': 'FRANCE 2',
|
||||
'epg_channel_id': 'France2.fr',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('845452'), '845452');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
expect(response.headers['X-Epg-Source'], 'panel-xmltv');
|
||||
// Seule la table des chaînes est interrogée : aucune action EPG.
|
||||
expect(actions, ['get_live_streams']);
|
||||
});
|
||||
|
||||
test('retombe sur player_api quand le dump ne couvre pas la chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
// Heure locale volontairement : le panneau émet des horodatages sans
|
||||
// fuseau, que le serveur relit comme de l'heure locale.
|
||||
final future = DateTime.now().add(const Duration(minutes: 10));
|
||||
String panelStamp(DateTime d) =>
|
||||
d.toIso8601String().substring(0, 19).replaceFirst('T', ' ');
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response('<tv></tv>', 200);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
if (action == 'get_simple_data_table') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'epg_listings': [
|
||||
{
|
||||
'title': base64Encode(utf8.encode('Match')),
|
||||
'description': '',
|
||||
'start': panelStamp(future),
|
||||
'end': panelStamp(future.add(const Duration(hours: 2))),
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('999'), '999');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect((body['programmes'] as List).single['title'], 'Match');
|
||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||
expect(actions, contains('get_simple_data_table'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../services/gpu_fallback.dart';
|
||||
|
||||
void main() {
|
||||
group('isGpuFailure', () {
|
||||
test('detects NVENC out-of-memory', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: '
|
||||
'out of memory (10)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects missing NVENC device', () {
|
||||
expect(
|
||||
isGpuFailure('[h264_nvenc @ 0x1] No capable devices found'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects CUDA errors', () {
|
||||
expect(
|
||||
isGpuFailure('cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects encoder opening failure', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'Error while opening encoder for output stream #0:0 - maybe '
|
||||
'incorrect parameters such as bit_rate, rate, width or height',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores upstream failures', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 '
|
||||
'Not Found',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores timeouts and null', () {
|
||||
expect(isGpuFailure('Timeout waiting for transcoder'), isFalse);
|
||||
expect(isGpuFailure(null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('GpuHealth', () {
|
||||
test('available until a failure, then again after cooldown', () {
|
||||
var now = DateTime.utc(2026, 10, 8, 20, 0);
|
||||
final health = GpuHealth(
|
||||
cooldown: const Duration(minutes: 2),
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
expect(health.available, isTrue);
|
||||
|
||||
health.markFailed();
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(minutes: 1, seconds: 59));
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
expect(health.available, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/logo_api.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Catalogue qui connaît uniquement `rmc-sport-2` et sert [bytes].
|
||||
LogoCatalog _catalog(List<int> bytes, {void Function()? onUse}) {
|
||||
return LogoCatalog(
|
||||
client: MockClient((request) async {
|
||||
onUse?.call();
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes(bytes, 200);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Request _logoRequest(String src, String name) => Request(
|
||||
'GET',
|
||||
Uri.http('localhost:8089', '/api/logo', {'src': src, 'name': name}),
|
||||
headers: {'cookie': 'session=abc'},
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LogoApi', () {
|
||||
test('sert le logo du panneau quand il répond', () async {
|
||||
Request? forwarded;
|
||||
var catalogUsed = false;
|
||||
final api = LogoApi(
|
||||
(request) {
|
||||
forwarded = request;
|
||||
return Response.ok(
|
||||
[9, 9],
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
},
|
||||
_catalog([1], onUse: () => catalogUsed = true),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(await response.read().expand((b) => b).toList(), [9, 9]);
|
||||
expect(response.headers['cache-control'], contains('max-age=86400'));
|
||||
expect(catalogUsed, isFalse);
|
||||
// Le proxy reçoit l'URL au format attendu et la session du client.
|
||||
expect(forwarded!.url.path, 'api/xtream/http://picons.example/logos/1.png');
|
||||
expect(forwarded!.headers['cookie'], 'session=abc');
|
||||
});
|
||||
|
||||
test('retombe sur le catalogue quand le panneau échoue', () async {
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['content-type'], 'image/png');
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('ignore une réponse panneau qui n’est pas une image', () async {
|
||||
// Page HTML de maintenance servie en 200 : pas un logo.
|
||||
final api = LogoApi(
|
||||
(_) => Response.ok('<html>', headers: {'content-type': 'text/html'}),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('rend une erreur mise en cache quand rien ne correspond', () async {
|
||||
// Un statut d'erreur laisse le client afficher son icône de repli ; une
|
||||
// image vide donnait une tuile blanche. 410 et non 404 : la Cascade du
|
||||
// serveur rattrape les 404 et perdrait le cache-control.
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([1]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - INCONNUE'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 410);
|
||||
expect(response.headers['cache-control'], contains('max-age=600'));
|
||||
});
|
||||
|
||||
test('cherche par nom quand le panneau ne fournit pas de logo', () async {
|
||||
var proxyCalled = false;
|
||||
final api = LogoApi(
|
||||
(_) {
|
||||
proxyCalled = true;
|
||||
return Response.notFound(null);
|
||||
},
|
||||
_catalog([7]),
|
||||
);
|
||||
|
||||
final response = await api.handle(_logoRequest('', 'FR - RMC SPORT 2'));
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(proxyCalled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans `-fr.png`).
|
||||
const _france = {
|
||||
'bein-sports-1-french',
|
||||
'bein-sports-2-french',
|
||||
'bein-sports',
|
||||
'canal-plus',
|
||||
'canal-plus-sport',
|
||||
'canal-plus-sport-360',
|
||||
'eurosport-1',
|
||||
'france-2',
|
||||
'france-24',
|
||||
'lequipe',
|
||||
'rmc-sport-1',
|
||||
'rmc-sport-2',
|
||||
'rmc-sport-access-1',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('matchLogoKey', () {
|
||||
String? match(String name) => matchLogoKey(name, _france);
|
||||
|
||||
test('correspondance exacte après nettoyage du nom panneau', () {
|
||||
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
|
||||
expect(match('FR - RMC SPORT 2 FHD'), 'rmc-sport-2');
|
||||
expect(match('FR: France 2 HD'), 'france-2');
|
||||
expect(match('|FR| EUROSPORT 1 UHD'), 'eurosport-1');
|
||||
});
|
||||
|
||||
test('traduit le « + » de Canal+', () {
|
||||
expect(match('FR - CANAL+ SPORT FHD'), 'canal-plus-sport');
|
||||
expect(match('FR - CANAL+'), 'canal-plus');
|
||||
});
|
||||
|
||||
test('accepte une variante plus longue du même identifiant', () {
|
||||
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
|
||||
expect(match('FR - BEIN SPORTS 1 FHD'), 'bein-sports-1-french');
|
||||
});
|
||||
|
||||
test('retombe sur la marque quand le numéro de canal est inconnu', () {
|
||||
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
|
||||
// celui de la marque vaut mieux qu'une tuile vide.
|
||||
expect(match('FR - RMC SPORT LIVE 5 FHD'), 'rmc-sport-1');
|
||||
expect(match('FR - CANAL+ SPORT 2'), 'canal-plus-sport');
|
||||
});
|
||||
|
||||
test('ne devine pas sur un seul mot', () {
|
||||
// « France » seul ne doit pas donner le logo de France 24.
|
||||
expect(match('FR - FRANCE'), isNull);
|
||||
});
|
||||
|
||||
test('rend null quand rien ne correspond', () {
|
||||
expect(match('FR - DAZN FHD'), isNull);
|
||||
expect(match(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('countryFolderFor', () {
|
||||
test('lit le préfixe pays du nom', () {
|
||||
expect(countryFolderFor('FR - TF1'), 'france');
|
||||
expect(countryFolderFor('BE: RTL TVI'), 'belgium');
|
||||
expect(countryFolderFor('|UK| BBC ONE'), 'united-kingdom');
|
||||
});
|
||||
|
||||
test('France par défaut sans préfixe reconnu', () {
|
||||
expect(countryFolderFor('TF1 HD'), 'france');
|
||||
});
|
||||
});
|
||||
|
||||
group('LogoCatalog', () {
|
||||
test('télécharge le logo apparié et le garde en cache', () async {
|
||||
final requested = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
requested.add(request.url.toString());
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
{'name': 'README.md', 'download_url': 'https://raw.example/r'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes([1, 2, 3], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
final first = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
final second = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
|
||||
expect(first, [1, 2, 3]);
|
||||
expect(second, [1, 2, 3]);
|
||||
// Une liste et un logo, pas un aller-retour par affichage.
|
||||
expect(requested, hasLength(2));
|
||||
});
|
||||
|
||||
test('rend null sans correspondance, sans télécharger d’image', () async {
|
||||
var downloads = 0;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
downloads++;
|
||||
return http.Response.bytes([1], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
expect(await catalog.logoFor('FR - CHAINE INCONNUE'), isNull);
|
||||
expect(downloads, 0);
|
||||
});
|
||||
|
||||
test('espace les tentatives quand la liste est injoignable', () async {
|
||||
var listings = 0;
|
||||
final client = MockClient((request) async {
|
||||
listings++;
|
||||
return http.Response('rate limited', 403);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
await catalog.logoFor('FR - RMC SPORT 2');
|
||||
await catalog.logoFor('FR - RMC SPORT 1');
|
||||
|
||||
expect(listings, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/recording_playlist.dart';
|
||||
|
||||
void main() {
|
||||
group('parseRecordingStart', () {
|
||||
test('reads a positive position in seconds', () {
|
||||
expect(parseRecordingStart('2700'), 2700);
|
||||
});
|
||||
|
||||
test('truncates fractions of a second', () {
|
||||
expect(parseRecordingStart('2700.9'), 2700);
|
||||
});
|
||||
|
||||
test('falls back to the beginning when absent or unusable', () {
|
||||
expect(parseRecordingStart(null), 0);
|
||||
expect(parseRecordingStart(''), 0);
|
||||
expect(parseRecordingStart('abc'), 0);
|
||||
expect(parseRecordingStart('-30'), 0);
|
||||
expect(parseRecordingStart('NaN'), 0);
|
||||
expect(parseRecordingStart('Infinity'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingOffsetKey', () {
|
||||
test('round-trips through its parser', () {
|
||||
for (final start in [0, 1, 2700, 86400]) {
|
||||
expect(parseRecordingOffsetKey(recordingOffsetKey(start)), start);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects keys that are not an offset', () {
|
||||
expect(parseRecordingOffsetKey('t'), isNull);
|
||||
expect(parseRecordingOffsetKey('segment_000.ts'), isNull);
|
||||
expect(parseRecordingOffsetKey('..'), isNull);
|
||||
expect(parseRecordingOffsetKey('t-30'), isNull);
|
||||
expect(parseRecordingOffsetKey('tabc'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rewriteRecordingPlaylist', () {
|
||||
const playlist = '#EXTM3U\n'
|
||||
'#EXT-X-VERSION:3\n'
|
||||
'#EXT-X-TARGETDURATION:4\n'
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_000.ts\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_001.ts\n';
|
||||
|
||||
test('binds every segment to its own session directory', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't2700');
|
||||
expect(rewritten, contains('t2700/segment_000.ts'));
|
||||
expect(rewritten, contains('t2700/segment_001.ts'));
|
||||
expect(rewritten, isNot(contains('\nsegment_')));
|
||||
});
|
||||
|
||||
test('leaves tags untouched', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't0');
|
||||
expect(rewritten, startsWith('#EXTM3U\n#EXT-X-VERSION:3\n'));
|
||||
expect(rewritten, contains('#EXT-X-PLAYLIST-TYPE:EVENT'));
|
||||
expect('#EXTINF:4.000000,'.allMatches(rewritten).length, 2);
|
||||
});
|
||||
|
||||
test('is idempotent on an already rewritten playlist', () {
|
||||
final once = rewriteRecordingPlaylist(playlist, 't60');
|
||||
expect(rewriteRecordingPlaylist(once, 't60'), once);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
|
||||
void main() {
|
||||
group('pipeWithBackpressure', () {
|
||||
test('relaie les données au client', () async {
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () {});
|
||||
|
||||
final received = <List<int>>[];
|
||||
final done = piped.listen(received.add).asFuture<void>();
|
||||
|
||||
source.add([1, 2, 3]);
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(received, [
|
||||
[1, 2, 3]
|
||||
]);
|
||||
});
|
||||
|
||||
test('propage la pause du client vers la source', () async {
|
||||
// Sans cette propagation, un client plus lent que le flux laissait
|
||||
// FFmpeg produire à pleine vitesse et le serveur empilait les paquets
|
||||
// en mémoire : latence qui dérive et lecture qui saccade.
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
final source = StreamController<List<int>>(
|
||||
onPause: () => paused = true,
|
||||
onResume: () => resumed = true,
|
||||
);
|
||||
|
||||
final subscription = pipeWithBackpressure(
|
||||
source.stream,
|
||||
onStop: () {},
|
||||
).listen((_) {});
|
||||
|
||||
subscription.pause();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(paused, isTrue);
|
||||
|
||||
subscription.resume();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(resumed, isTrue);
|
||||
|
||||
await subscription.cancel();
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand le client se déconnecte', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final subscription =
|
||||
pipeWithBackpressure(source.stream, onStop: () => stopped++)
|
||||
.listen((_) {});
|
||||
|
||||
await subscription.cancel();
|
||||
|
||||
expect(stopped, 1);
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand la source se termine', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () => stopped++);
|
||||
final done = piped.listen((_) {}).asFuture<void>();
|
||||
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(stopped, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
group('ensureFresh', () {
|
||||
test('ne retélécharge pas tant que l’index est frais', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
_fixture(now, now.add(const Duration(minutes: 30))),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isTrue);
|
||||
});
|
||||
|
||||
test('espace les tentatives après un échec', () async {
|
||||
// Sans ce recul, une source morte était retéléchargée à chaque
|
||||
// consultation du guide : une requête sortante par chaîne affichée.
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
retryBackoff: const Duration(minutes: 15),
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('nope', 500);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('horizon', () {
|
||||
test('écarte les programmes au-delà de l’horizon d’indexation', () {
|
||||
// Un dump national couvre sept jours : tout garder ferait grossir
|
||||
// l'index pour un guide qui n'affiche que le programme courant.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [],
|
||||
horizon: const Duration(hours: 48),
|
||||
);
|
||||
final far = DateTime.now().toUtc().add(const Duration(days: 5));
|
||||
|
||||
expect(
|
||||
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
///
|
||||
/// Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
/// tournerait en boucle.
|
||||
class MediaProbe {
|
||||
MediaProbe._();
|
||||
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
static String ffprobePath() {
|
||||
final fromEnv = Platform.environment['FFPROBE_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) {
|
||||
return '/usr/local/bin/ffprobe';
|
||||
}
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier introuvable, conteneur sans durée).
|
||||
static Future<double?> duration(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'duration',
|
||||
args: const [
|
||||
'-show_entries', 'format=duration',
|
||||
],
|
||||
);
|
||||
final seconds = double.tryParse(value ?? '');
|
||||
if (seconds == null || !seconds.isFinite || seconds <= 0) return null;
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…),
|
||||
/// ou `null` si indéterminable.
|
||||
static Future<String?> videoCodec(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'videoCodec',
|
||||
args: const [
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
],
|
||||
);
|
||||
return (value == null || value.isEmpty) ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
static Future<String?> _probe(
|
||||
String path, {
|
||||
required String field,
|
||||
required List<String> args,
|
||||
}) async {
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = File(path).statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = '$field@$path';
|
||||
final cached = _cache[key];
|
||||
if (cached != null && cached.matches(stat)) return cached.value;
|
||||
|
||||
try {
|
||||
final result = await Process.run(ffprobePath(), [
|
||||
'-v', 'error',
|
||||
...args,
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]);
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
final output = (result.stdout as String).trim().split('\n').first.trim();
|
||||
if (output.isEmpty || output == 'N/A') return null;
|
||||
_cache[key] = _CacheEntry(stat, output);
|
||||
return output;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final int size;
|
||||
final int mtimeMs;
|
||||
final String value;
|
||||
|
||||
_CacheEntry(FileStat stat, this.value)
|
||||
: size = stat.size,
|
||||
mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
|
||||
bool matches(FileStat stat) =>
|
||||
stat.size == size &&
|
||||
stat.modified.millisecondsSinceEpoch == mtimeMs;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Relaie [source] vers le client en conservant la contre-pression.
|
||||
///
|
||||
/// POURQUOI : un `StreamController` nu ne transmet pas la pause de son
|
||||
/// abonné à la source. Quand le navigateur lit plus lentement que FFmpeg ne
|
||||
/// produit — réseau domestique, onglet en arrière-plan —, le serveur empilait
|
||||
/// donc les paquets en mémoire sans jamais ralentir le producteur : la
|
||||
/// consommation grimpe et la lecture dérive derrière le direct, ce qui se voit
|
||||
/// à l'écran comme des saccades.
|
||||
///
|
||||
/// [onStop] est appelé une seule fois, quand le client se déconnecte ou que la
|
||||
/// source se termine : c'est là qu'on tue le processus FFmpeg, sinon il en
|
||||
/// reste un par zapping.
|
||||
Stream<List<int>> pipeWithBackpressure(
|
||||
Stream<List<int>> source, {
|
||||
required void Function() onStop,
|
||||
}) {
|
||||
late final StreamController<List<int>> controller;
|
||||
late final StreamSubscription<List<int>> subscription;
|
||||
var stopped = false;
|
||||
|
||||
void stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
onStop();
|
||||
}
|
||||
|
||||
controller = StreamController<List<int>>(
|
||||
onPause: () => subscription.pause(),
|
||||
onResume: () => subscription.resume(),
|
||||
onCancel: () {
|
||||
final cancelled = subscription.cancel();
|
||||
stop();
|
||||
return cancelled;
|
||||
},
|
||||
);
|
||||
|
||||
subscription = source.listen(
|
||||
controller.add,
|
||||
onError: controller.addError,
|
||||
onDone: () {
|
||||
controller.close();
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
+4
-3
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -27,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
|
||||
|
||||
@@ -191,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
|
||||
@@ -201,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
|
||||
|
||||
+13
-13
@@ -149,10 +149,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -588,26 +588,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1073,10 +1073,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.12"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1113,10 +1113,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.4.3"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1262,5 +1262,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
+1
-1
@@ -4,7 +4,7 @@ publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
sdk: ^3.13.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
||||
Generated
+2063
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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: ®ex::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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"}))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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"], "é");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Décodage base64 des titres/descriptions EPG du panneau Xtream.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE};
|
||||
|
||||
/// Texte décodé si [raw] est du base64 valide contenant de l'UTF-8 valide,
|
||||
/// sinon `None` (l'appelant garde alors la valeur brute, comme
|
||||
/// `utf8.decode(base64Decode(x))` qui levait une exception).
|
||||
pub fn decode_text(raw: &str) -> Option<String> {
|
||||
let bytes = STANDARD
|
||||
.decode(raw.as_bytes())
|
||||
.or_else(|_| URL_SAFE.decode(raw.as_bytes()))
|
||||
.ok()?;
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_le_base64_du_panneau() {
|
||||
assert_eq!(
|
||||
decode_text("Sm91cm5hbCBkZSAyMGg=").as_deref(),
|
||||
Some("Journal de 20h")
|
||||
);
|
||||
assert_eq!(
|
||||
decode_text("TCdpbmZvIGR1IHNvaXI=").as_deref(),
|
||||
Some("L'info du soir")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garde_un_titre_en_clair() {
|
||||
assert_eq!(decode_text("Journal"), None);
|
||||
assert_eq!(decode_text("Le 13h"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Masque les identifiants IPTV des URL avant qu'elles n'atteignent les logs.
|
||||
//!
|
||||
//! Xtream Codes embarque les identifiants à la fois dans la query string
|
||||
//! (`?username=u&password=p`) et dans le chemin
|
||||
//! (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static QUERY_CREDS: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)(username|password)=[^&\s]*").unwrap());
|
||||
|
||||
static PATH_CREDS: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)/(live|movie|series)/[^/\s]+/[^/\s]+/").unwrap());
|
||||
|
||||
/// Renvoie [url] avec les identifiants remplacés par `***`.
|
||||
pub fn redact_url(url: &str) -> String {
|
||||
let redacted = QUERY_CREDS.replace_all(url, "$1=***");
|
||||
PATH_CREDS
|
||||
.replace_all(&redacted, "/$1/***/***/")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn masque_la_query_string() {
|
||||
let out = redact_url(
|
||||
"http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams",
|
||||
);
|
||||
assert!(!out.contains("john"));
|
||||
assert!(!out.contains("hunter2"));
|
||||
assert!(out.contains("username=***"));
|
||||
assert!(out.contains("password=***"));
|
||||
assert!(out.contains("action=get_live_streams"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masque_les_segments_de_chemin() {
|
||||
for kind in ["live", "movie", "series"] {
|
||||
let out = redact_url(&format!("http://srv:8080/{kind}/john/hunter2/12345.ts"));
|
||||
assert!(!out.contains("john"), "{kind}");
|
||||
assert!(!out.contains("hunter2"), "{kind}");
|
||||
assert!(out.contains(&format!("/{kind}/***/***/12345.ts")), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laisse_les_url_sans_identifiants() {
|
||||
let url = "http://srv:8080/images/logo.png";
|
||||
assert_eq!(redact_url(url), url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
//!
|
||||
//! Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
//! liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
//! et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
//! tournerait en boucle.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
struct CacheEntry {
|
||||
size: u64,
|
||||
mtime_ms: u128,
|
||||
value: String,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
pub fn ffprobe_path() -> String {
|
||||
if let Ok(path) = std::env::var("FFPROBE_PATH")
|
||||
&& !path.is_empty()
|
||||
{
|
||||
return path;
|
||||
}
|
||||
if cfg!(target_os = "linux") && std::path::Path::new("/usr/local/bin/ffprobe").exists() {
|
||||
return "/usr/local/bin/ffprobe".to_string();
|
||||
}
|
||||
"ffprobe".to_string()
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `None` si elle n'est pas mesurable.
|
||||
pub async fn duration(path: &str) -> Option<f64> {
|
||||
let value = probe(path, "duration", &["-show_entries", "format=duration"]).await?;
|
||||
let seconds: f64 = value.parse().ok()?;
|
||||
(seconds.is_finite() && seconds > 0.0).then_some(seconds)
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…).
|
||||
pub async fn video_codec(path: &str) -> Option<String> {
|
||||
let value = probe(
|
||||
path,
|
||||
"videoCodec",
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
(!value.is_empty()).then(|| value.to_lowercase())
|
||||
}
|
||||
|
||||
fn stat(path: &str) -> Option<(u64, u128)> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis();
|
||||
Some((meta.len(), mtime))
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
async fn probe(path: &str, field: &str, args: &[&str]) -> Option<String> {
|
||||
let (size, mtime_ms) = stat(path)?;
|
||||
let key = format!("{field}@{path}");
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(entry) = cache.get(&key)
|
||||
&& entry.size == size
|
||||
&& entry.mtime_ms == mtime_ms
|
||||
{
|
||||
return Some(entry.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new(ffprobe_path())
|
||||
.args(["-v", "error"])
|
||||
.args(args)
|
||||
.args(["-of", "default=noprint_wrappers=1:nokey=1", path])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let first = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if first.is_empty() || first == "N/A" {
|
||||
return None;
|
||||
}
|
||||
CACHE.lock().unwrap().insert(
|
||||
key,
|
||||
CacheEntry {
|
||||
size,
|
||||
mtime_ms,
|
||||
value: first.clone(),
|
||||
},
|
||||
);
|
||||
Some(first)
|
||||
}
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user