Serveur réécrit en Rust

Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

+1 -2
View File
@@ -6,8 +6,7 @@
.packages
.pub-cache
build/
bin/.dart_tool
bin/.packages
server/target
ios/
android/
windows/
+12 -6
View File
@@ -40,12 +40,18 @@ jobs:
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.5
- run: dart pub get
- run: dart analyze
- run: dart test
toolchain: 1.98.1
components: clippy
- 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
+3
View File
@@ -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
+7 -1
View File
@@ -11,10 +11,16 @@ Since you are currently using Docker, you likely don't have these installed on y
* Extract the zip file to `C:\src\flutter` (for example).
* Add `flutter/bin` to your **System PATH**.
2. **Verify Installation**
2. **Install Rust** (the server is written in Rust)
* Download `rustup-init.exe` from [rustup.rs](https://rustup.rs) and accept the defaults
(it installs the MSVC toolchain; Visual Studio Build Tools with the "C++ build tools"
workload are required).
3. **Verify Installation**
* Open a new terminal.
* Run `flutter doctor`.
* Ensure it says "Flutter ... channel stable".
* Run `cargo --version`.
## Building the App
+7
View File
@@ -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
+22 -8
View File
@@ -31,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)
@@ -48,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 \
@@ -81,7 +95,7 @@ RUN mkdir -p /app/data /app/web /app/recordings /tmp/xtremflow_streams \
&& chown -R xtremuser:xtremuser /app /tmp/xtremflow_streams
COPY --from=builder /app/build/web /app/web
COPY --from=builder /app/bin/server /app/server
COPY --from=server-builder /server/target/release/xtremflow-server /app/server
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/server /app/entrypoint.sh
+13 -19
View File
@@ -1,45 +1,39 @@
# Dockerfile optimisé pour mises à jour rapides (Fast-Path)
# Ce Dockerfile assume que les fichiers Flutter Web ont déjà été compilés en local
# dans le dossier `build/web`.
# dans le dossier `build/web`. Seul le serveur Rust est compilé ici.
FROM rust:1.98.1-slim-bookworm AS server-builder
WORKDIR /server
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/xtremflow-server* target/release/deps/xtremflow_server*
COPY server/src ./src
RUN cargo build --release --locked
FROM debian:stable-slim
# Éviter les prompts à l'installation
ENV DEBIAN_FRONTEND=noninteractive
# Installation des dépendances minimales (Dart + FFmpeg)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
unzip \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Installation de Dart SDK (version spécifique pour compatibilité)
RUN wget https://storage.googleapis.com/dart-archive/channels/stable/release/3.3.0/sdk/dartsdk-linux-x64-release.zip -O /tmp/dart-sdk.zip && \
unzip /tmp/dart-sdk.zip -d /usr/lib && \
rm /tmp/dart-sdk.zip
ENV PATH="/usr/lib/dart-sdk/bin:${PATH}"
# Création de l'utilisateur non-root
RUN useradd -m xtremuser
WORKDIR /app
# Création des dossiers de données avec permissions
RUN mkdir -p /app/data/logs /app/data/tmp /app/web && \
RUN mkdir -p /app/data/logs /app/data/tmp /app/web /app/recordings && \
chown -R xtremuser:xtremuser /app
# Copie des fichiers pré-compilés du serveur (doit être compilé en local si possible, sinon ici)
COPY --chown=xtremuser:xtremuser bin/ /app/bin/
RUN cd bin && dart pub get
COPY --from=server-builder /server/target/release/xtremflow-server /app/server
# Copie des fichiers pré-compilés du frontend (build/web doit exister en local)
COPY --chown=xtremuser:xtremuser build/web/ /app/web/
USER xtremuser
EXPOSE 8089
# Commande de lancement (compilation JIT du serveur pour rapidité de build, ou EXE si déjà fait)
# Pour une rapidité maximale de "build", on lance le script dart directement car la compilation EXE prend du temps.
CMD ["dart", "bin/server.dart", "--port", "8089", "--path", "/app/web"]
CMD ["/app/server", "--port", "8089", "--path", "/app/web"]
+17 -14
View File
@@ -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** |
@@ -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
-6
View File
@@ -1,11 +1,5 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# bin/ is a standalone Dart package (server) with its own pubspec and
# analysis; it is analyzed by the backend CI job via `dart analyze`.
- bin/**
linter:
rules:
- prefer_const_constructors
-81
View File
@@ -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);
}
}
-163
View File
@@ -1,163 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class AuthHandler {
final AppDatabase db;
AuthHandler(this.db);
Router get router {
final router = Router();
router.post('/login', _login);
router.post('/logout', _logout);
router.get('/me', _getCurrentUser);
return router;
}
/// POST /api/auth/login
Future<Response> _login(Request request) async {
try {
print('[Auth] Login attempt received');
final bodyStr = await request.readAsString();
final payload = jsonDecode(bodyStr) as Map<String, dynamic>;
final username = payload['username'] as String?;
final password = payload['password'] as String?;
if (username == null || password == null) {
print('[Auth] Missing username or password');
return Response(400, body: jsonEncode({
'success': false,
'error': 'Username and password are required',
}), headers: {'Content-Type': 'application/json'},);
}
print('[Auth] Verifying credentials for user: $username');
// Verify credentials
final user = db.verifyCredentials(username, password);
if (user == null) {
print('[Auth] Invalid credentials for user: $username');
return Response(401, body: jsonEncode({
'success': false,
'error': 'Invalid credentials',
}), headers: {'Content-Type': 'application/json'},);
}
print('[Auth] User verified, creating session for userId: ${user.id}');
// Create session
final session = db.createSession(user.id);
// HttpOnly session cookie so same-origin media requests (hls.js inside
// the player iframe cannot send Authorization headers) are authenticated.
final maxAge = session.expiresAt.difference(DateTime.now()).inSeconds;
return Response.ok(jsonEncode({
'success': true,
'user': user.toJson(),
'token': session.token,
}), headers: {
'Content-Type': 'application/json',
'Set-Cookie':
'session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=$maxAge',
},);
} catch (e, stackTrace) {
print('[Auth] ERROR during login: $e');
print('[Auth] Stack trace: $stackTrace');
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/auth/logout
Future<Response> _logout(Request request) async {
try {
final token = _extractToken(request);
if (token == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
db.deleteSession(token);
return Response.ok(jsonEncode({
'success': true,
}), headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0',
},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// GET /api/auth/me
Future<Response> _getCurrentUser(Request request) async {
try {
final token = _extractToken(request);
if (token == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final session = db.findSessionByToken(token);
if (session == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Invalid or expired session',
}), headers: {'Content-Type': 'application/json'},);
}
final user = db.findUserById(session.userId);
if (user == null) {
return Response(404, body: jsonEncode({
'success': false,
'error': 'User not found',
}), headers: {'Content-Type': 'application/json'},);
}
return Response.ok(jsonEncode({
'user': user.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// Extract token from Authorization header or cookie
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
if (authHeader != null && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Try cookie
final cookie = request.headers['cookie'];
if (cookie != null) {
final parts = cookie.split(';');
for (final part in parts) {
final trimmed = part.trim();
if (trimmed.startsWith('session=')) {
return trimmed.substring(8);
}
}
}
return null;
}
}
-473
View File
@@ -1,473 +0,0 @@
import 'dart:convert';
import 'dart:isolate';
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.
//
// Le dump externe passe ensuite AVANT l'interrogation chaîne par
// chaîne : il est déjà indexé en mémoire (réponse instantanée), alors
// que `player_api` coûte plusieurs secondes par chaîne et échouait en
// prod — ce qui laissait toute la grille sans guide.
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
('xtream', () => _xtreamProgrammes(playlist, channelId)),
];
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);
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
// qui vient souvent d'une source momentanément indisponible et ne
// doit pas priver la chaîne de guide pendant une demi-heure.
_cache[cacheKey] = _CacheEntry(
data: jsonStr,
expiresAt: DateTime.now()
.add(Duration(minutes: found ? 30 : 5)),
);
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 http.Response response;
try {
response = await _http
.get(Uri.parse(url))
.timeout(const Duration(seconds: 20));
} catch (e) {
// Délai dépassé, connexion coupée… : une source parmi d'autres, pas
// une raison de répondre 500 (ce qui privait la chaîne des sources
// suivantes). L'exception recopie l'URL, identifiants compris.
print('[EpgApi] player_api $action indisponible pour $channelId : '
'${LogRedactor.redactUrl('$e')}');
continue;
}
if (response.statusCode != 200) continue;
Map<String, dynamic> parsed;
try {
parsed = transformEpgData(json.decode(response.body), channelId);
} catch (_) {
continue;
}
final programmes = (parsed['programmes'] as List)
.whereType<Map<String, dynamic>>()
.toList();
if (programmes.isNotEmpty) return programmes;
}
return const [];
}
/// Le guide contient-il un programme en cours ou à venir ?
///
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
/// 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) {
// L'exception peut recopier l'URL `player_api`/`xmltv.php`, identifiants
// compris.
print(
'[EpgApi] source XMLTV indisponible pour $channelId : '
'${LogRedactor.redactUrl('$e')}',
);
return const [];
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis la réponse
/// `get_live_streams`.
static (Map<String, String>, Map<String, String>) _parseChannelTable(
String body,
) {
final epgIds = <String, String>{};
final names = <String, String>{};
final decoded = json.decode(body);
if (decoded is List) {
for (final item in decoded) {
if (item is! Map) continue;
final streamId = item['stream_id']?.toString();
if (streamId == null || streamId.isEmpty) continue;
final epgId = item['epg_channel_id']?.toString();
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
final name = item['name']?.toString();
if (name != null && name.isNotEmpty) names[streamId] = name;
}
}
return (epgIds, names);
}
/// Téléchargements de la table des chaînes en cours, par compte.
final Map<String, Future<_ChannelMap>> _channelMapsInFlight = {};
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
///
/// Les requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
/// son propre `get_live_streams` (7 Mo en prod).
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
final key = '${playlist.dns}|${playlist.username}';
final cached = _channelMaps[key];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return Future.value(cached);
}
return _channelMapsInFlight[key] ??=
_loadChannelMap(playlist, key).whenComplete(() {
_channelMapsInFlight.remove(key);
});
}
Future<_ChannelMap> _loadChannelMap(
PlaylistConfig playlist,
String key,
) async {
final url = '${playlist.dns}/player_api.php'
'?username=${playlist.username}&password=${playlist.password}'
'&action=get_live_streams';
http.Response? response;
try {
response =
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
} catch (e) {
print('[EpgApi] table des chaînes indisponible : '
'${LogRedactor.redactUrl('$e')}');
}
var epgIds = <String, String>{};
var names = <String, String>{};
if (response != null && response.statusCode == 200) {
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
// correspondance avec le guide.
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
try {
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
} catch (e) {
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
}
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
final map = _ChannelMap(
epgIds: epgIds,
names: names,
expiresAt: DateTime.now().add(
names.isEmpty ? const Duration(minutes: 2) : 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});
}
-72
View File
@@ -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();
}
}
-70
View File
@@ -1,70 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
/// Journal des incidents de lecture remontés par le lecteur web.
///
/// POURQUOI : les freezes (image et son figés puis reprise) 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] …`) : on sait alors si un
/// freeze vient d'un buffer à sec, d'une erreur du démuxeur ou d'une
/// relance du flux.
///
/// Entrée non fiable : seuls des champs connus, courts et typés passent.
const _events = {
'start', 'first_frame', 'stall', 'seek', 'mpegts_error', 'recreate',
'fallback_hls', 'hls_error', 'profile', 'error_shown', 'heartbeat',
};
const _numericFields = {
't', 'ms', 'buffer', 'pos', 'from', 'to', 'stalls', 'stallMs', 'seeks',
'errors', 'rate',
};
const _textFields = {'session', 'ch', 'mode', 'profile', 'detail'};
/// Ligne de journal pour l'événement [raw], ou null s'il est invalide.
String? formatPlayerEvent(Object? raw) {
if (raw is! Map) return null;
final event = raw['event'];
if (event is! String || !_events.contains(event)) return null;
final parts = <String>[event];
for (final key in _textFields) {
final value = raw[key];
if (value is String && value.isNotEmpty) {
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
// journaux, jamais d'identifiants recopiés depuis une URL d'erreur.
final safe = value
.replaceAll(RegExp(r'https?://\S+'), '<url>')
.replaceAll(RegExp(r'[^\w.\-:/<>]'), '_');
parts.add('$key=${safe.length > 60 ? safe.substring(0, 60) : safe}');
}
}
for (final key in _numericFields) {
final value = raw[key];
if (value is num && value.isFinite) {
parts.add('$key=${value is int ? value : value.toStringAsFixed(1)}');
}
}
return '[Player] ${parts.join(' ')}';
}
Future<Response> handlePlayerLog(Request request) async {
final body = await request.readAsString();
if (body.length > 4096) return Response(413);
Object? decoded;
try {
decoded = jsonDecode(body);
} catch (_) {
return Response.badRequest();
}
final events = decoded is List ? decoded.take(20) : [decoded];
for (final event in events) {
final line = formatPlayerEvent(event);
if (line != null) print(line);
}
return Response(204);
}
-191
View File
@@ -1,191 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class PlaylistsHandler {
final AppDatabase db;
PlaylistsHandler(this.db);
Router get router {
final router = Router();
router.get('/', _getPlaylists);
router.post('/', _createPlaylist);
router.put('/<id>', _updatePlaylist);
router.delete('/<id>', _deletePlaylist);
return router;
}
/// GET /api/playlists
Future<Response> _getPlaylists(Request request) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final playlists = db.getPlaylists(userId);
return Response.ok(jsonEncode({
'playlists': playlists.map((p) => p.toJson()).toList(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/playlists
Future<Response> _createPlaylist(Request request) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final name = payload['name'] as String?;
final serverUrl = payload['serverUrl'] as String?;
final username = payload['username'] as String?;
final password = payload['password'] as String?;
final dns = payload['dns'] as String?;
if (name == null || serverUrl == null || username == null || password == null) {
return Response(400, body: jsonEncode({
'success': false,
'error': 'Missing required fields',
}), headers: {'Content-Type': 'application/json'},);
}
final playlist = db.createPlaylist(
userId: userId,
name: name,
serverUrl: serverUrl,
username: username,
password: password,
dns: dns ?? serverUrl,
);
return Response.ok(jsonEncode({
'success': true,
'playlist': playlist.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// PUT /api/playlists/:id
Future<Response> _updatePlaylist(Request request, String id) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
// Verify playlist belongs to user
final existing = db.getPlaylistById(id);
if (existing == null) {
return Response(404, body: jsonEncode({
'success': false,
'error': 'Playlist not found',
}), headers: {'Content-Type': 'application/json'},);
}
if (existing.userId != userId) {
return Response(403, body: jsonEncode({
'success': false,
'error': 'Forbidden',
}), headers: {'Content-Type': 'application/json'},);
}
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final name = payload['name'] as String? ?? existing.name;
final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl;
final username = payload['username'] as String? ?? existing.username;
// Empty password means "keep current": clients never receive the real
// password, so edit forms send it back blank.
final incomingPassword = payload['password'] as String?;
final password = (incomingPassword == null || incomingPassword.isEmpty)
? existing.password
: incomingPassword;
final dns = payload['dns'] as String? ?? existing.dns;
final playlist = db.updatePlaylist(
playlistId: id,
name: name,
serverUrl: serverUrl,
username: username,
password: password,
dns: dns,
);
return Response.ok(jsonEncode({
'success': true,
'playlist': playlist.toJson(),
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/playlists/:id
Future<Response> _deletePlaylist(Request request, String id) async {
try {
final userId = request.context['userId'] as String?;
if (userId == null) {
return Response(401, body: jsonEncode({
'success': false,
'error': 'Unauthorized',
}), headers: {'Content-Type': 'application/json'},);
}
// Verify playlist belongs to user
final existing = db.getPlaylistById(id);
if (existing == null) {
return Response(404,body: jsonEncode({
'success': false,
'error': 'Playlist not found',
}), headers: {'Content-Type': 'application/json'},);
}
if (existing.userId != userId) {
return Response(403, body: jsonEncode({
'success': false,
'error': 'Forbidden',
}), headers: {'Content-Type': 'application/json'},);
}
db.deletePlaylist(id);
return Response.ok(jsonEncode({
'success': true,
}), headers: {'Content-Type': 'application/json'},);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-334
View File
@@ -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'},
);
}
};
}
}
-43
View File
@@ -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]}',
);
}
-290
View File
@@ -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é'});
}
}
-155
View File
@@ -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'},
);
}
}
}
-63
View File
@@ -1,63 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
class SettingsHandler {
final AppDatabase db;
SettingsHandler(this.db);
Router get router {
final router = Router();
// GET /api/settings
router.get('/', (Request req) {
// Get userId from context (set by auth middleware)
final userId = req.context['userId'] as String?;
if (userId == null) {
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
}
final settingsJson = db.getUserSettings(userId);
// If no settings exist, return empty object or default
return Response.ok(
settingsJson ?? '{}',
headers: {'content-type': 'application/json'},
);
});
// POST /api/settings
router.post('/', (Request req) async {
// Get userId from context (set by auth middleware)
final userId = req.context['userId'] as String?;
if (userId == null) {
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
}
try {
final body = await req.readAsString();
// Validate JSON
try {
jsonDecode(body);
} catch (e) {
return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'}));
}
db.updateUserSettings(userId, body);
return Response.ok(
body,
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: jsonEncode({'error': 'Failed to save settings: $e'}),
);
}
});
return router;
}
}
-129
View File
@@ -1,129 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart';
import '../services/simple_recorder.dart';
/// 🎯 Simple Recording API - 3 endpoints only!
class SimpleRecordingApi {
final AppDatabase db;
final SimpleRecorder recorder;
SimpleRecordingApi(this.db, this.recorder);
/// Setup routes
Router get router {
final router = Router();
router.post('/record/now', _recordNow);
router.post('/record/schedule', _scheduleRecord);
router.post('/record/stop/<channelId>', _stopRecord);
router.get('/record/list', _listRecordings);
router.get('/record/active', _getActive);
return router;
}
/// 🟢 POST /record/now
/// Start recording RIGHT NOW
/// Body: { channel_id, stream_url, title, duration_minutes }
Future<Response> _recordNow(Request request) async {
try {
final data = jsonDecode(await request.readAsString());
final id = await recorder.startRecording(
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Recording',
duration: Duration(minutes: data['duration_minutes'] ?? 60),
);
return Response.ok(
jsonEncode({
'status': 'recording',
'id': id,
'message': 'Recording started!',
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 🔵 POST /record/schedule
/// Schedule recording for later
/// Body: { channel_id, stream_url, title, start_time (ISO), end_time (ISO) }
Future<Response> _scheduleRecord(Request request) async {
try {
final data = jsonDecode(await request.readAsString());
final id = await recorder.scheduleRecording(
channelId: data['channel_id'],
streamUrl: data['stream_url'],
title: data['title'] ?? 'Recording',
startTime: DateTime.parse(data['start_time']),
endTime: DateTime.parse(data['end_time']),
);
return Response.ok(
jsonEncode({
'status': 'scheduled',
'id': id,
'message': 'Recording scheduled!',
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 🔴 POST /record/stop/<channelId>
/// Stop recording immediately
Future<Response> _stopRecord(Request request, String channelId) async {
try {
await recorder.stopRecording(channelId);
return Response.ok(
jsonEncode({'status': 'stopped', 'message': 'Recording stopped!'}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.badRequest(
body: jsonEncode({'error': '$e'}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// 📋 GET /record/list
/// List all recordings
Response _listRecordings(Request request) {
final recordings = db.getAllRecordings();
return Response.ok(
jsonEncode({
'total': recordings.length,
'recordings': recordings.map((r) => r.toMap()).toList(),
}),
headers: {'Content-Type': 'application/json'},
);
}
/// 🟢 GET /record/active
/// List currently recording
Response _getActive(Request request) {
return Response.ok(
jsonEncode({
'active': recorder.getActive(),
'count': recorder.getActive().length,
}),
headers: {'Content-Type': 'application/json'},
);
}
}
File diff suppressed because it is too large. Load diff
-133
View File
@@ -1,133 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Le panneau autorise aussi le HLS ; reste à
/// savoir lequel des deux formats démarre le plus vite, et où part le temps
/// (redirection, en-têtes, premier octet). Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
Future<Map<String, Object?>> probeUpstream(
PlaylistConfig playlist,
String streamId, {
http.Client? client,
}) async {
final c = client ?? http.Client();
try {
final base = '${playlist.dns}/live/${playlist.username}/'
'${playlist.password}/$streamId';
final ts = await _probeStream(c, Uri.parse('$base.ts'));
// Laisser le panneau libérer la connexion (comptes à une connexion).
await Future<void>.delayed(const Duration(seconds: 2));
final hls = await _probeHls(c, Uri.parse('$base.m3u8'));
return {'stream': streamId, 'ts': ts, 'm3u8': hls};
} finally {
if (client == null) c.close();
}
}
/// Suit les redirections à la main pour chronométrer chaque saut.
Future<({http.StreamedResponse? response, List<Map<String, Object?>> hops, Uri url})>
_open(http.Client c, Uri url, Stopwatch sw) async {
final hops = <Map<String, Object?>>[];
var current = url;
for (var i = 0; i < 5; i++) {
final request = http.Request('GET', current)
..followRedirects = false
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18';
final response =
await c.send(request).timeout(const Duration(seconds: 20));
hops.add({
'host': current.host,
'status': response.statusCode,
'headersMs': sw.elapsedMilliseconds,
});
final location = response.headers['location'];
if (const {301, 302, 303, 307, 308}.contains(response.statusCode) &&
location != null) {
await response.stream.drain<void>().catchError((_) {});
current = current.resolve(location);
continue;
}
return (response: response, hops: hops, url: current);
}
return (response: null, hops: hops, url: current);
}
Future<Map<String, Object?>> _probeStream(http.Client c, Uri url) async {
final sw = Stopwatch()..start();
try {
final opened = await _open(c, url, sw);
final response = opened.response;
if (response == null || response.statusCode != 200) {
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
}
var bytes = 0;
int? firstByteMs;
final sub = response.stream.listen(null);
final done = Completer<void>();
sub.onData((chunk) {
firstByteMs ??= sw.elapsedMilliseconds;
bytes += chunk.length;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
if (bytes > 1000000 && !done.isCompleted) done.complete();
});
sub.onDone(() {
if (!done.isCompleted) done.complete();
});
await done.future.timeout(const Duration(seconds: 15), onTimeout: () {});
await sub.cancel();
return {
'hops': opened.hops,
'firstByteMs': firstByteMs,
'firstMegabyteMs': bytes > 1000000 ? sw.elapsedMilliseconds : null,
};
} catch (e) {
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
}
}
Future<Map<String, Object?>> _probeHls(http.Client c, Uri url) async {
final sw = Stopwatch()..start();
try {
final opened = await _open(c, url, sw);
final response = opened.response;
if (response == null || response.statusCode != 200) {
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
}
final body = await response.stream.bytesToString();
final playlistMs = sw.elapsedMilliseconds;
final lines = const LineSplitter().convert(body);
final segments =
lines.where((l) => l.isNotEmpty && !l.startsWith('#')).toList();
final target = lines
.firstWhere((l) => l.startsWith('#EXT-X-TARGETDURATION'),
orElse: () => '')
.split(':')
.last;
if (segments.isEmpty) {
return {'hops': opened.hops, 'playlistMs': playlistMs, 'segments': 0};
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
final pick = segments.length >= 2
? segments[segments.length - 2]
: segments.last;
final segment = await _probeStream(c, opened.url.resolve(pick));
return {
'hops': opened.hops,
'playlistMs': playlistMs,
'segments': segments.length,
'targetDuration': target,
'segment': segment,
'segmentStartedAtMs': playlistMs,
};
} catch (e) {
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
}
}
-180
View File
@@ -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'},
);
}
}
}
-78
View File
@@ -1,78 +0,0 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
/// Authenticated gateway to the Xtream `player_api.php` endpoint.
///
/// The frontend never sees the Xtream credentials: it calls
/// `GET /api/xtream-api?action=...` with its session token and the server
/// injects the username/password of the user's playlist before forwarding.
class XtreamApiHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final http.Client _client = http.Client();
XtreamApiHandler(this._getPlaylist);
/// Query parameters the client is allowed to pass through to Xtream.
static const _allowedParams = {
'action',
'category_id',
'stream_id',
'series_id',
'vod_id',
'limit',
'type',
};
Future<Response> handle(Request request) async {
final playlist = await _getPlaylist(request);
if (playlist == null) {
return Response.forbidden(
jsonEncode({'error': 'No playlist configured'}),
headers: {'Content-Type': 'application/json'},
);
}
final params = <String, String>{
'username': playlist.username,
'password': playlist.password,
};
request.url.queryParameters.forEach((key, value) {
if (_allowedParams.contains(key)) params[key] = value;
});
final base = Uri.parse('${playlist.dns}/player_api.php');
final targetUrl = base.replace(queryParameters: params);
try {
final proxyRequest = http.Request('GET', targetUrl)
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
..headers['Accept'] = '*/*'
..followRedirects = true;
final response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
return Response(
response.statusCode,
body: response.stream,
headers: {
'Content-Type':
response.headers['content-type'] ?? 'application/json',
'Cache-Control': 'no-store',
},
);
} catch (e) {
print(
'[XtreamApi] Error forwarding to ${LogRedactor.redactUrl(targetUrl.toString())}: $e',
);
return Response.internalServerError(
body: jsonEncode({'error': 'Upstream Xtream API error'}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
-795
View File
@@ -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();
}
}
-106
View File
@@ -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;
}
-248
View File
@@ -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;
};
};
}
-67
View File
@@ -1,67 +0,0 @@
class Playlist {
final String id;
final String userId;
final String name;
final String serverUrl;
final String username;
final String password;
final String? dns;
final DateTime createdAt;
final DateTime updatedAt;
Playlist({
required this.id,
required this.userId,
required this.name,
required this.serverUrl,
required this.username,
required this.password,
this.dns,
required this.createdAt,
required this.updatedAt,
});
factory Playlist.fromMap(Map<String, dynamic> map) {
return Playlist(
id: map['id'] as String,
userId: map['user_id'] as String,
name: map['name'] as String,
serverUrl: map['server_url'] as String,
username: map['username'] as String,
password: map['password'] as String,
dns: map['dns'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'name': name,
'serverUrl': serverUrl,
'username': username,
// Never expose the Xtream password to clients; the backend injects
// credentials server-side (see xtream_api_handler.dart).
'password': '',
'dns': dns,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'name': name,
'server_url': serverUrl,
'username': username,
'password': password,
'dns': dns,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
}
-52
View File
@@ -1,52 +0,0 @@
/// Pure Dart model for Xtream Codes playlist configuration
/// This is a server-side copy without Hive/Equatable dependencies
class PlaylistConfig {
final String id;
final String name;
/// Xtream server URL (without trailing slash)
final String dns;
final String username;
/// Xtream password
final String password;
final DateTime createdAt;
final bool isActive;
const PlaylistConfig({
required this.id,
required this.name,
required this.dns,
required this.username,
required this.password,
required this.createdAt,
this.isActive = true,
});
/// Creates a copy of this playlist with updated fields
PlaylistConfig copyWith({
String? id,
String? name,
String? dns,
String? username,
String? password,
DateTime? createdAt,
bool? isActive,
}) {
return PlaylistConfig(
id: id ?? this.id,
name: name ?? this.name,
dns: dns ?? this.dns,
username: username ?? this.username,
password: password ?? this.password,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
);
}
/// Generate Xtream API base URL
String get apiBaseUrl => '$dns/player_api.php';
}
-100
View File
@@ -1,100 +0,0 @@
import 'dart:convert';
class Recording {
final String id;
final String userId;
final String channelId;
final String streamUrl;
final String title;
final DateTime startTime;
final DateTime endTime;
final String status; // 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
final String? filePath;
final String? errorReason;
final DateTime createdAt;
final DateTime updatedAt;
Recording({
required this.id,
required this.userId,
required this.channelId,
required this.streamUrl,
required this.title,
required this.startTime,
required this.endTime,
required this.status,
this.filePath,
this.errorReason,
required this.createdAt,
required this.updatedAt,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'channel_id': channelId,
'stream_url': streamUrl,
'title': title,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'status': status,
'file_path': filePath,
'error_reason': errorReason,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
factory Recording.fromMap(Map<String, dynamic> map) {
return Recording(
id: map['id'] ?? '',
userId: map['user_id'] ?? '',
channelId: map['channel_id'] ?? '',
streamUrl: map['stream_url'] ?? '',
title: map['title'] ?? '',
startTime: DateTime.parse(map['start_time']),
endTime: DateTime.parse(map['end_time']),
status: map['status'] ?? 'scheduled',
filePath: map['file_path'],
errorReason: map['error_reason'],
createdAt: DateTime.parse(map['created_at']),
updatedAt: DateTime.parse(map['updated_at']),
);
}
String toJson() => json.encode(toMap());
factory Recording.fromJson(String source) =>
Recording.fromMap(json.decode(source));
Recording copyWith({
String? id,
String? userId,
String? channelId,
String? streamUrl,
String? title,
DateTime? startTime,
DateTime? endTime,
String? status,
String? filePath,
String? errorReason,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Recording(
id: id ?? this.id,
userId: userId ?? this.userId,
channelId: channelId ?? this.channelId,
streamUrl: streamUrl ?? this.streamUrl,
title: title ?? this.title,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
status: status ?? this.status,
filePath: filePath ?? this.filePath,
errorReason: errorReason ?? this.errorReason,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
-37
View File
@@ -1,37 +0,0 @@
class Session {
final String id;
final String userId;
final String token;
final DateTime expiresAt;
final DateTime createdAt;
Session({
required this.id,
required this.userId,
required this.token,
required this.expiresAt,
required this.createdAt,
});
factory Session.fromMap(Map<String, dynamic> map) {
return Session(
id: map['id'] as String,
userId: map['user_id'] as String,
token: map['token'] as String,
expiresAt: DateTime.parse(map['expires_at'] as String),
createdAt: DateTime.parse(map['created_at'] as String),
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'token': token,
'expires_at': expiresAt.toIso8601String(),
'created_at': createdAt.toIso8601String(),
};
}
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
-31
View File
@@ -1,31 +0,0 @@
class User {
final String id;
final String username;
final bool isAdmin;
final DateTime createdAt;
User({
required this.id,
required this.username,
required this.isAdmin,
required this.createdAt,
});
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as String,
username: map['username'] as String,
isAdmin: (map['is_admin'] as int) == 1,
createdAt: DateTime.parse(map['created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'isAdmin': isAdmin,
'createdAt': createdAt.toIso8601String(),
};
}
}
-477
View File
@@ -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.13.0 <4.0.0"
-25
View File
@@ -1,25 +0,0 @@
name: xtremflow_server
description: Backend server with API proxy
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.13.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
-502
View File
@@ -1,502 +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';
import 'utils/asset_versioning.dart';
import 'api/upstream_probe.dart';
import 'api/player_log.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,'
'https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,'
'https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz')
.split(',')
.map((u) => u.trim())
.where((u) => u.isNotEmpty)
.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),
)
// Incidents de lecture remontés par le lecteur web (freezes, erreurs),
// écrits dans les logs pour le diagnostic.
..post(
'/api/player-log',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(handlePlayerLog),
)
// 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'},
);
});
// GET /api/admin/upstream-probe?stream=<id> : chronomètre l'ouverture
// d'une chaîne chez le fournisseur en .ts et en .m3u8 (diagnostic du
// délai de zap ; aucun identifiant dans la réponse).
router.get('/upstream-probe', (Request req) async {
final user = req.context['user'] as User?;
if (user == null || !user.isAdmin) {
return Response.forbidden(
jsonEncode({'error': 'Admin access required'}),
);
}
final streamId = req.url.queryParameters['stream'] ?? '';
if (!RegExp(r'^[0-9]+$').hasMatch(streamId)) {
return Response.badRequest(body: 'stream=<id numérique> requis');
}
final playlist = await getPlaylist(req);
if (playlist == null) return Response.forbidden('No playlist');
final result = await probeUpstream(playlist, streamId);
return Response.ok(
jsonEncode(result),
headers: {'content-type': 'application/json'},
);
});
return router(request);
}),
);
// Les libs de lecture sont servies depuis l'image, plus depuis un CDN : si
// elles manquent (build web incomplet, copie Docker partielle), le lecteur
// échoue côté navigateur avec un simple « Échec du chargement » sans que
// rien ne l'ait signalé au démarrage. On le dit ici, une fois, clairement.
for (final lib in const ['hls.min.js', 'mpegts.min.js']) {
final file = File('$webPath/vendor/$lib');
if (!file.existsSync()) {
print(
'[Server] ATTENTION: web/vendor/$lib absent de $webPath — '
'la lecture échouera dans le navigateur.',
);
}
}
// Create static handler
final baseStaticHandler = createStaticHandler(
webPath,
defaultDocument: 'index.html',
listDirectories: false,
);
// Points d'entrée réécrits avec `?v=<empreinte>` sur leurs JS : voir
// asset_versioning.dart (un proxy qui cache les .js servait l'ancienne
// app et l'ancien lecteur après chaque déploiement).
final versionedEntryPoints = buildVersionedEntryPoints(webPath);
print('[Server] Assets versionnés : ${versionedEntryPoints.keys.join(', ')}');
// Wrap static handler to enforce cache policies
FutureOr<Response> staticHandler(Request request) async {
final path = request.url.path;
final versioned =
versionedEntryPoints[path.isEmpty ? 'index.html' : path];
if (versioned != null && request.method == 'GET') {
return Response.ok(
versioned,
headers: {
'Content-Type': path.endsWith('.js')
? 'text/javascript; charset=utf-8'
: 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
'Expires': '0',
},
);
}
final response = await baseStaticHandler(request);
// Disable cache for entry points to ensure updates are seen immediately.
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
// versions: keep them cacheable or every player open re-downloads them.
final 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);
};
};
}
-109
View File
@@ -1,109 +0,0 @@
import 'dart:io';
import 'dart:async';
class CleanupService {
final List<Directory> _targetDirectories = [];
Timer? _timer;
final Duration _defaultMaxAge;
CleanupService({Duration defaultMaxAge = const Duration(hours: 24)})
: _defaultMaxAge = defaultMaxAge;
/// Add a directory to be monitored
void addTarget(Directory dir) {
if (!dir.existsSync()) {
try {
dir.createSync(recursive: true);
print('CleanupService: Created missing directory: ${dir.path}');
} catch (e) {
print('CleanupService: Should monitor ${dir.path} but it does not exist and could not be created: $e');
return;
}
}
_targetDirectories.add(dir);
print('CleanupService: Monitoring ${dir.path}');
}
/// Start the periodic cleanup task
void start({Duration interval = const Duration(hours: 6)}) {
_timer?.cancel();
print('CleanupService: Starting scheduler (Interval: ${interval.inHours}h)');
// Run immediately on start
runCleanup();
_timer = Timer.periodic(interval, (_) => runCleanup());
}
/// Stop the scheduler
void stop() {
_timer?.cancel();
print('CleanupService: Scheduler stopped');
}
/// Trigger a manual cleanup
Future<Map<String, dynamic>> runCleanup() async {
print('CleanupService: Running cleanup task...');
int deletedCount = 0;
int freedBytes = 0;
List<String> errors = [];
for (final dir in _targetDirectories) {
if (!dir.existsSync()) continue;
try {
final result = await _cleanDirectory(dir, _defaultMaxAge);
deletedCount += result.count;
freedBytes += result.bytes;
} catch (e) {
print('CleanupService: Error processing ${dir.path}: $e');
errors.add('Error in ${dir.path}: $e');
}
}
final summary = {
'status': 'complete',
'deleted_files': deletedCount,
'freed_bytes': freedBytes,
'timestamp': DateTime.now().toIso8601String(),
'errors': errors,
};
print('CleanupService: Cleanup complete. Deleted $deletedCount files, freed ${(freedBytes / 1024 / 1024).toStringAsFixed(2)} MB');
return summary;
}
Future<({int count, int bytes})> _cleanDirectory(Directory dir, Duration maxAge) async {
int count = 0;
int bytes = 0;
final now = DateTime.now();
final threshold = now.subtract(maxAge);
try {
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
final stat = await entity.stat();
// Check Modified AND Accessed time (if available) to be safe
// Ideally we use modified time for temp files
if (stat.modified.isBefore(threshold)) {
final fileSize = stat.size;
await entity.delete();
count++;
bytes += fileSize;
// print('CleanupService: Deleted ${entity.path} (Age: ${now.difference(stat.modified).inHours}h)');
}
} catch (e) {
// Ignore individual file errors (locked files etc)
// print('CleanupService: Failed to delete ${entity.path}: $e');
}
}
}
} catch (e) {
print('CleanupService: Failed to list ${dir.path}: $e');
rethrow;
}
return (count: count, bytes: bytes);
}
}
-293
View File
@@ -1,293 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../utils/log_redactor.dart';
/// One running FFmpeg transcoding session (live, VOD or recording playback).
class FfmpegSession {
final String id;
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);
/// Vrai si [getOrStart] lancerait un nouveau processus FFmpeg pour [id] —
/// donc une nouvelle connexion au fournisseur.
bool needsStart(String id, {required bool isLive}) {
final existing = _sessions[id];
if (existing == null) return true;
if (!existing.exited) return false;
// A VOD/recording transcode that finished cleanly is still fully
// playable from its segments — reuse it instead of re-transcoding.
return isLive || existing.exitCode != 0 || !_playlistComplete(existing);
}
/// Returns the existing healthy session or starts a new FFmpeg process.
///
/// [argsBuilder] receives the session working directory and returns the
/// 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 && !needsStart(id, isLive: isLive)) {
existing.touch();
return existing;
}
if (existing != null) {
// 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);
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from 'http://…' ») :
// masquer les identifiants Xtream avant de journaliser.
print('[FFmpeg $id] ${LogRedactor.redactUrl(data)}');
});
process.exitCode.then((code) {
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;
}
}
}
-43
View File
@@ -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);
}
-239
View File
@@ -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;
}
-126
View File
@@ -1,126 +0,0 @@
/// Pure decision logic for the recording scheduler, extracted for testability.
enum RecordingAction {
/// Start the recording now.
start,
/// Capacity reached but the window is still open: retry on a later tick.
wait,
/// The end time passed before the recording could start.
fail,
/// Not yet due — nothing to do.
none,
}
/// Decides what to do with a scheduled recording on a scheduler tick.
RecordingAction decideRecordingAction({
required DateTime now,
required DateTime startTime,
required DateTime endTime,
required int activeCount,
required int maxConcurrent,
}) {
final nowUtc = now.toUtc();
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
if (activeCount >= maxConcurrent) return RecordingAction.wait;
return RecordingAction.start;
}
/// Ce que le planificateur doit faire d'un enregistrement resté au statut
/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond
/// (redémarrage du conteneur, crash de l'isolate…).
enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
finish,
/// Rien d'exploitable n'a été capturé.
fail,
}
/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick.
OrphanAction decideOrphanAction({
required DateTime now,
required DateTime endTime,
required bool hasFile,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining) return OrphanAction.resume;
return hasFile ? OrphanAction.finish : OrphanAction.fail;
}
/// Ce que le planificateur doit faire quand un processus FFmpeg se termine.
enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé.
complete,
/// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre.
retry,
/// Arrêt prématuré sans rien d'exploitable.
fail,
}
/// Nombre d'échecs FFmpeg consécutifs tolérés avant d'abandonner un
/// enregistrement dont la fenêtre est encore ouverte.
///
/// Le compteur est remis à zéro dès qu'un lancement a duré assez longtemps
/// pour être considéré sain : une coupure toutes les dix minutes ne doit pas
/// finir par épuiser le quota.
const int maxFfmpegAttempts = 30;
/// Décide de la suite à donner à la sortie d'un processus FFmpeg.
///
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
PostExitAction decidePostExitAction({
required DateTime now,
required DateTime endTime,
required int exitCode,
required int consecutiveFailures,
required bool hasFile,
int maxAttempts = maxFfmpegAttempts,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) {
return PostExitAction.retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
final cleanExit = exitCode == 0 || exitCode == 255;
return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail;
}
/// Durée de capture à demander à FFmpeg (`-t`).
///
/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur la
/// durée théorique du programme : un démarrage tardif (ou une relance après
/// coupure) ne doit pas décaler la fin de l'enregistrement.
Duration captureDuration({
required DateTime now,
required DateTime endTime,
Duration minimum = const Duration(seconds: 30),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
return remaining < minimum ? minimum : remaining;
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
Duration ffmpegRetryDelay(int consecutiveFailures) {
final steps = (consecutiveFailures - 1).clamp(0, 4);
final seconds = 3 * (1 << steps);
return Duration(seconds: seconds > 30 ? 30 : seconds);
}
-970
View File
@@ -1,970 +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
// `ProcessException` liste les arguments FFmpeg, URL de la source
// comprise : masquer les identifiants avant de journaliser.
print(
'[RecordingScheduler] ERREUR dans _startRecording: '
'${LogRedactor.redactUrl('$e')}\n$st',
);
_db.updateRecordingStatus(
recording.id,
'failed',
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: '
'${LogRedactor.redactUrl('$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',
);
}
}
}
-238
View File
@@ -1,238 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
class SimpleRecorder {
final AppDatabase db;
final String recordingsDir;
// Active recordings (streamId -> process)
final Map<String, _ActiveRecording> _active = {};
SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'});
/// Initialize recordings directory
Future<void> init() async {
final dir = Directory(recordingsDir);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
print('🎬 Recorder ready -> $recordingsDir');
}
/// Start recording immediately
Future<String> startRecording({
required String channelId,
required String streamUrl,
required String title,
required Duration duration,
}) async {
if (_active.containsKey(channelId)) {
throw Exception('Already recording: $title');
}
final recordingId = const Uuid().v4();
final now = DateTime.now();
final endTime = now.add(duration);
// Create recording in DB
db.createRecording(
userId: 'system',
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: now,
endTime: endTime,
);
// Generate safe filename
final filename = _safeName(title, recordingId);
final filepath = '$recordingsDir/$filename';
// Start FFmpeg process
try {
final ffmpeg = await Process.start('ffmpeg', [
'-y',
'-fflags', '+genpts+igndts', // FORCE generation of missing timestamps for MKV/MP4 compatibility
'-i',
streamUrl,
'-c',
'copy',
'-t',
'${duration.inSeconds}',
filepath,
]);
_active[channelId] = _ActiveRecording(
id: recordingId,
process: ffmpeg,
filepath: filepath,
endTime: endTime,
);
// Update DB
db.updateRecordingStatus(recordingId, 'recording', filePath: filepath);
// Auto-stop when time is up or process ends
ffmpeg.exitCode.then((code) {
if (code == 0 || code == 255) { // 255 is usually returned when gracefully stopped via q/SIGTERM
db.updateRecordingStatus(recordingId, 'completed');
} else {
db.updateRecordingStatus(
recordingId,
'failed',
errorReason: 'FFmpeg crashed with exit code $code',
);
}
_active.remove(channelId);
print('✅ Recording finished: $title (Code: $code)');
}).catchError((e) {
db.updateRecordingStatus(
recordingId,
'failed',
errorReason: 'FFmpeg error',
);
_active.remove(channelId);
});
print('🔴 Recording: $title ($duration)');
return recordingId;
} catch (e) {
db.updateRecordingStatus(recordingId, 'failed', errorReason: '$e');
rethrow;
}
}
/// Schedule recording for later
Future<String> scheduleRecording({
required String channelId,
required String streamUrl,
required String title,
required DateTime startTime,
required DateTime endTime,
}) async {
final recordingId = const Uuid().v4();
// Create in DB with 'scheduled' status
db.createRecording(
userId: 'system',
channelId: channelId,
streamUrl: streamUrl,
title: title,
startTime: startTime,
endTime: endTime,
);
// Schedule auto-start
final delay = startTime.difference(DateTime.now());
if (delay.isNegative) {
throw Exception('Start time is in the past');
}
Timer(delay, () async {
try {
await startRecording(
channelId: channelId,
streamUrl: streamUrl,
title: title,
duration: endTime.difference(startTime),
);
} catch (e) {
print('❌ Auto-start failed: $e');
}
});
print('⏰ Scheduled: $title at ${startTime.toLocal()}');
return recordingId;
}
/// Stop recording immediately
Future<void> stopRecording(String channelId) async {
final active = _active[channelId];
if (active == null) return;
active.process.kill();
db.updateRecordingStatus(active.id, 'completed');
_active.remove(channelId);
print('⏹️ Stopped: $channelId');
}
/// Check scheduled recordings (call every minute)
Future<void> checkScheduled() async {
final now = DateTime.now();
for (final rec in db.getAllRecordings()) {
if (rec.status != 'scheduled') continue;
if (now.isBefore(rec.startTime)) continue;
try {
await startRecording(
channelId: rec.channelId,
streamUrl: rec.streamUrl,
title: rec.title,
duration: rec.endTime.difference(rec.startTime),
);
} catch (e) {
print('❌ Auto-start error: $e');
db.updateRecordingStatus(rec.id, 'failed', errorReason: '$e');
}
}
}
/// Get active recordings
List<Map<String, dynamic>> getActive() {
return _active.entries
.map(
(e) => {
'id': e.value.id,
'channel': e.key,
'filepath': e.value.filepath,
'endsAt': e.value.endTime.toIso8601String(),
},
)
.toList();
}
/// Cleanup old recordings (keep last 20)
Future<void> cleanupOld({int keepCount = 20}) async {
final dir = Directory(recordingsDir);
final files = dir.listSync().whereType<File>().toList()
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
if (files.length > keepCount) {
for (final file in files.skip(keepCount)) {
await file.delete();
print('🗑️ Deleted: ${file.path}');
}
}
}
String _safeName(String title, String id) {
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final timestamp =
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
return '${safe}_$timestamp.mkv'; // The user explicitly mandated MKV or MP4.
}
void dispose() {
for (final rec in _active.values) {
rec.process.kill();
}
}
}
/// Internal: Active recording tracking
class _ActiveRecording {
final String id;
final Process process;
final String filepath;
final DateTime endTime;
_ActiveRecording({
required this.id,
required this.process,
required this.filepath,
required this.endTime,
});
}
-183
View File
@@ -1,183 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
/// Connexions ouvertes vers le fournisseur Xtream, par compte.
///
/// POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
/// simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
/// lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
/// ou la fin du téléchargement pour un film, le temps de détecter la
/// déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
/// films « indisponibles » et les zaps qui échouent.
///
/// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
/// connexions ORPHELINES du même compte (plus personne ne les consomme),
/// les plus anciennes d'abord, jusqu'à lui faire de la place.
///
/// Un flux encore regardé n'est jamais coupé. Une première version coupait
/// sans distinction (« le dernier gagne ») : deux lecteurs ouverts sur un
/// compte à une connexion se coupaient alors en boucle, chacun relançant
/// aussitôt — plus aucune image nulle part (constaté en prod). Le second
/// lecteur est désormais refusé par le panneau, comme avant, et le premier
/// continue.
///
/// Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
class UpstreamSlots {
final DateTime Function() _now;
final Map<String, _Slot> _slots = {};
int _nextToken = 0;
UpstreamSlots({DateTime Function()? now}) : _now = now ?? DateTime.now;
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
///
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
/// le même identifiant ne doit pas être désinscrite par la fin de
/// l'ancien processus.
///
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
/// connexion active n'est jamais coupée par [makeRoom].
int register({
required String id,
required String account,
required void Function() release,
bool Function()? isActive,
}) {
final token = ++_nextToken;
_slots[id] = _Slot(token, account, _now(), release, isActive);
return token;
}
void unregister(String id, {int? token}) {
final slot = _slots[id];
if (slot == null) return;
if (token != null && slot.token != token) return;
_slots.remove(id);
}
int countFor(String account) =>
_slots.values.where((s) => s.account == account).length;
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
///
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
/// d'interrompre un autre spectateur.
List<String> makeRoom(String account, {required int max, required String keep}) {
if (max <= 0) return const [];
final others = _slots.entries
.where((e) => e.value.account == account && e.key != keep)
.toList();
final excess = others.length - (max - 1);
if (excess <= 0) return const [];
final orphans = others.where((e) => !e.value.active).toList()
..sort((a, b) => a.value.since.compareTo(b.value.since));
final freed = <String>[];
for (final entry in orphans.take(excess)) {
_slots.remove(entry.key);
try {
entry.value.release();
} catch (_) {}
freed.add(entry.key);
}
return freed;
}
}
class _Slot {
final int token;
final String account;
final DateTime since;
final void Function() release;
final bool Function()? isActive;
_Slot(this.token, this.account, this.since, this.release, this.isActive);
bool get active {
try {
return isActive?.call() ?? false;
} catch (_) {
return false;
}
}
}
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
/// même quota chez le fournisseur.
String accountKeyOf(PlaylistConfig p) => '${p.dns}|${p.username}';
/// `user_info.max_connections` d'une réponse `player_api.php`, ou null.
int? parseMaxConnections(Object? json) {
if (json is! Map) return null;
final info = json['user_info'];
if (info is! Map) return null;
final raw = info['max_connections'];
final value = raw is int ? raw : int.tryParse('$raw');
return (value != null && value > 0) ? value : null;
}
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
///
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
/// répondre (mesuré en prod), délai qui s'ajoutait au démarrage du premier
/// flux puis de chaque flux suivant l'expiration du cache. La valeur connue
/// (même périmée), sinon [fallback], est rendue immédiatement ; la lecture
/// chez le panneau se fait en arrière-plan. Sans risque : le quota ne sert
/// qu'à décider quels flux ORPHELINS couper.
class AccountLimits {
final Duration ttl;
final Future<int?> Function(PlaylistConfig) _fetch;
final Map<String, ({int max, DateTime at})> _cache = {};
final Set<String> _refreshing = {};
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
/// cas de loin le plus courant chez les fournisseurs Xtream.
static const fallback = 1;
AccountLimits({
this.ttl = const Duration(minutes: 10),
Future<int?> Function(PlaylistConfig)? fetch,
}) : _fetch = fetch ?? _fetchFromPanel;
Future<int> maxFor(PlaylistConfig p) async {
final key = accountKeyOf(p);
final cached = _cache[key];
if (cached == null || DateTime.now().difference(cached.at) >= ttl) {
_refresh(key, p);
}
return cached?.max ?? fallback;
}
void _refresh(String key, PlaylistConfig p) {
if (!_refreshing.add(key)) return; // une seule lecture à la fois
Future<int?>.sync(() => _fetch(p))
.then<int?>((v) => v, onError: (Object _) => null)
.then((value) {
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
if (value != null) _cache[key] = (max: value, at: DateTime.now());
}).whenComplete(() => _refreshing.remove(key));
}
static Future<int?> _fetchFromPanel(PlaylistConfig p) async {
final uri = Uri.parse('${p.dns}/player_api.php').replace(
queryParameters: {'username': p.username, 'password': p.password},
);
final client = http.Client();
try {
final response = await client.get(uri, headers: {
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
}).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
return parseMaxConnections(jsonDecode(response.body));
} finally {
client.close();
}
}
}
-534
View File
@@ -1,534 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:xml/xml_events.dart';
import '../utils/log_redactor.dart';
/// Guide TV construit à partir de dumps XMLTV publics.
///
/// Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou
/// 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();
final direct = _lookup(channelId, displayName);
if (direct.isNotEmpty) return direct;
// Chaîne décalée sans guide propre (« TF1 +1 » absent des dumps) : le
// guide de la chaîne principale, une heure plus tard, est exact.
if (displayName != null && _plusOne.hasMatch(displayName)) {
final base = displayName.replaceAll(_plusOne, ' ');
final hits = _lookup(null, base);
return [
for (final p in hits)
XmltvProgramme(
title: p.title,
description: p.description,
start: p.start.add(const Duration(hours: 1)),
stop: p.stop.add(const Duration(hours: 1)),
),
];
}
return const [];
}
static final _plusOne = RegExp(r'\+\s*1(?![0-9])');
List<XmltvProgramme> _lookup(String? channelId, String? displayName) {
final keys = [
normalizeKey(channelId),
if (displayName != null) ...nameKeys(displayName),
// Clés souples en dernier recours, dans leur propre espace de noms.
_looseIndexKey(looseKey(channelId)),
if (displayName != null)
_looseIndexKey(looseKey(cleanChannelName(displayName).name)),
];
for (final key in keys) {
if (key.isEmpty) continue;
final hit = _index[key];
if (hit != null && hit.isNotEmpty) return hit;
}
return const [];
}
static String _looseIndexKey(String loose) => loose.isEmpty ? '' : '~$loose';
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
static const _looseNoise = {
'channel', 'tv', 'hd', 'fhd', 'uhd', 'sd', '4k', 'hevc',
};
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
static const _countryCodes = {
'fr', 'be', 'ch', 'lu', 'mc', 'ca', 'uk', 'us', 'es', 'it', 'de', 'pt',
'nl',
};
/// Clé « souple » : sans code pays final ni mots génériques
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
///
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
/// rattrape les variantes d'écriture sans fausser les correspondances
/// exactes.
static String looseKey(String? raw) {
if (raw == null) return '';
final folded = StringBuffer();
for (final rune in raw.toLowerCase().runes) {
final char = String.fromCharCode(rune);
folded.write(_accents[char] ?? char);
}
final tokens = folded
.toString()
.split(RegExp(r'[^a-z0-9]+'))
.where((t) => t.isNotEmpty)
.toList();
if (tokens.length > 1 && _countryCodes.contains(tokens.last)) {
tokens.removeLast();
}
tokens.removeWhere(_looseNoise.contains);
return tokens.join();
}
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
static final _countryPrefix = RegExp(
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
);
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
/// chaîne elle-même.
static final _qualitySuffix = RegExp(
r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|'
r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$',
caseSensitive: false,
);
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
///
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
///
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
/// de TF1.
static List<String> nameKeys(String name) {
final keys = <String>[normalizeKey(name)];
final cleaned = cleanChannelName(name);
final country = cleaned.country;
final base = normalizeKey(cleaned.name);
if (base.isNotEmpty) {
keys.add(base);
keys.add('$base${country ?? 'fr'}');
}
return keys.where((k) => k.isNotEmpty).toSet().toList();
}
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
/// où les dumps écrivent `France.3.-.Alpes.fr`.
static final _franceAbbrev = RegExp(r'^F\s?([2-5])\b', caseSensitive: false);
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
/// marqueurs de qualité ; [country] = code pays du préfixe, s'il y en a.
static ({String name, String? country}) cleanChannelName(String raw) {
var cleaned = raw;
String? country;
final prefix = _countryPrefix.firstMatch(cleaned);
if (prefix != null) {
country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase();
cleaned = cleaned.substring(prefix.end);
}
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
// chiffre, ni ponctuation utile.
cleaned = cleaned
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
.trim();
String previous;
do {
previous = cleaned;
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
} while (cleaned != previous && cleaned.isNotEmpty);
cleaned = cleaned.replaceFirstMapped(
_franceAbbrev,
(m) => 'France ${m[1]}',
);
return (name: cleaned, country: country);
}
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
/// partagent le même téléchargement.
Future<void> ensureFresh() {
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) {
// La source peut être le `xmltv.php` du panneau, dont l'URL porte les
// identifiants de l'abonné en clair : ne jamais la journaliser telle
// quelle (elle finirait dans `docker logs`). Le texte de l'exception
// est masqué aussi, un `ClientException` recopiant l'URL demandée.
final safeUrl = LogRedactor.redactUrl(url);
try {
final parsed = await _downloadAndParse(url);
// Première source servie gagne : les suivantes ne comblent que les
// chaînes encore absentes.
for (final entry in parsed.entries) {
merged.putIfAbsent(entry.key, () => entry.value);
}
ok++;
print(
'[XmltvEpg] $safeUrl : ${parsed.length} chaînes indexées',
);
} catch (e) {
print(
'[XmltvEpg] $safeUrl : échec (${LogRedactor.redactUrl('$e')})',
);
}
}
if (ok == 0) {
// Garder l'index précédent plutôt que de servir un guide vide.
_failedAt = DateTime.now();
print('[XmltvEpg] aucune source disponible, index précédent conservé');
return;
}
_index = merged;
_indexedAt = DateTime.now();
_failedAt = null;
print('[XmltvEpg] index prêt : ${merged.length} chaînes');
}
/// Télécharge [url] puis l'indexe dans un isolate dédié.
///
/// Le serveur n'a qu'une boucle d'événements : décompresser et parser un
/// dump de plusieurs milliers de chaînes dessus la gelait pendant des
/// secondes. Plus rien d'autre ne répondait — en particulier le relais
/// `turbo.ts`, qui cessait d'envoyer des octets au lecteur : la lecture
/// se coupait systématiquement au démarrage, au moment précis où l'écran
/// des chaînes demandait le guide.
Future<Map<String, List<XmltvProgramme>>> _downloadAndParse(
String url,
) async {
final response =
await _client.get(Uri.parse(url)).timeout(downloadTimeout);
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}');
}
// Variables locales : la fermeture envoyée à l'isolate ne doit pas
// capturer `this` (le client HTTP n'est pas transférable).
final bytes = response.bodyBytes;
final retention = this.retention;
final horizon = this.horizon;
return Isolate.run(
() => _decodeAndParse(bytes, retention: retention, horizon: horizon),
);
}
static Map<String, List<XmltvProgramme>> _decodeAndParse(
Uint8List raw, {
required Duration retention,
required Duration horizon,
}) {
List<int> bytes = raw;
// Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on
// regarde le nombre magique plutôt que de se fier aux en-têtes.
if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) {
bytes = gzip.decode(bytes);
}
return _parse(
utf8.decode(bytes, allowMalformed: true),
retention: retention,
horizon: horizon,
);
}
/// Exposé pour les tests.
Map<String, List<XmltvProgramme>> parseForTest(String xml) =>
_parse(xml, retention: retention, horizon: horizon);
static Map<String, List<XmltvProgramme>> _parse(
String xml, {
required Duration retention,
required Duration horizon,
}) {
final cutoff = DateTime.now().toUtc().subtract(retention);
final limit = DateTime.now().toUtc().add(horizon);
final byChannel = <String, List<XmltvProgramme>>{};
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
// les indexe TOUS : seul le premier l'était, et c'était souvent la
// variante la moins proche du nom annoncé par le panneau.
final aliases = <String, String>{};
final displayNames = <String>[];
String? channelId;
String? programmeChannel;
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();
displayNames.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':
displayName.write(text);
}
} else if (event is XmlEndElementEvent) {
switch (event.name) {
case 'display-name':
displayNames.add(displayName.toString());
displayName.clear();
case 'channel':
final id = normalizeKey(channelId);
for (final raw in displayNames) {
final name = normalizeKey(raw);
if (id.isNotEmpty && name.isNotEmpty && id != name) {
aliases.putIfAbsent(name, () => id);
}
}
// Clés souples (espace de noms « ~ ») : premier arrivé gagne.
if (id.isNotEmpty) {
for (final raw in [channelId, ...displayNames]) {
final loose = _looseIndexKey(looseKey(raw));
if (loose.isNotEmpty) aliases.putIfAbsent(loose, () => id);
}
}
channelId = null;
case 'programme':
if (programmeChannel != null &&
start != null &&
stop != null &&
stop.isAfter(cutoff) &&
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,
};
}
-57
View File
@@ -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);
});
});
}
-47
View File
@@ -1,47 +0,0 @@
import 'package:test/test.dart';
import '../utils/asset_versioning.dart';
void main() {
group('versionAssetRefs', () {
test('versionne la balise script du lecteur', () {
expect(
versionAssetRefs(
'<script src="xf-player-core.js"></script>',
{'xf-player-core.js': 'abc123'},
),
'<script src="xf-player-core.js?v=abc123"></script>',
);
});
test('versionne mainJsPath dans la config de build Flutter', () {
expect(
versionAssetRefs(
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}',
{'main.dart.js': 'f00'},
),
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}',
);
});
test('ne touche ni un nom qui ne fait que contenir l\'asset ni une ref déjà versionnée', () {
const html = '<script src="vendor/xf-player-core.js"></script>'
'<script src="xf-player-core.js?v=old"></script>';
expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html);
});
test('guillemets simples acceptés', () {
expect(
versionAssetRefs("<link href='xf-player.css'>", {'xf-player.css': '1'}),
"<link href='xf-player.css?v=1'>",
);
});
});
group('contentVersion', () {
test('stable pour un même contenu, différente sinon', () {
expect(contentVersion([1, 2, 3]), contentVersion([1, 2, 3]));
expect(contentVersion([1, 2, 3]), isNot(contentVersion([1, 2, 4])));
expect(contentVersion([1, 2, 3]), hasLength(12));
});
});
}
-329
View File
@@ -1,329 +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'));
});
// Dump externe couvrant TF1, et panneau dont le xmltv.php est vide.
String externalDump() {
final now = DateTime.now().toUtc();
return xmltvDump(now, now.add(const Duration(hours: 1)))
.replaceAll('France2.fr', 'TF1.fr')
.replaceAll('FRANCE 2', 'TF1');
}
EpgApi apiWith(http.Client client) => EpgApi(
(_) async => playlist,
httpClient: client,
xmltv: XmltvEpgService(
sourceUrls: const ['http://ext.example/epg.xml'],
client: client,
),
panelXmltvBuilder: (config) => XmltvEpgService(
sourceUrls: ['${config.dns}/xmltv.php'],
client: client,
),
);
test('un panneau qui plante ne prive plus la chaîne du dump externe',
() async {
// Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide
// externe (qui couvrait pourtant la chaîne) n'étant jamais consulté.
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
if (action == 'get_live_streams') {
// Octets UTF-8 et JSON sans charset annoncé, comme le panneau réel.
return http.Response.bytes(
utf8.encode(jsonEncode([
{'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''},
])),
200,
headers: {'content-type': 'application/json'},
);
}
throw http.ClientException('Connection closed', url);
});
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 200);
expect(response.headers['X-Epg-Source'], 'xmltv');
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
});
test('le dump externe passe avant l\'appel lent chaîne par chaîne',
() async {
final actions = <String>[];
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
actions.add(action);
if (action == 'get_live_streams') {
return http.Response(
jsonEncode([
{'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
]),
200,
);
}
return http.Response('{}', 200);
});
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
expect(response.headers['X-Epg-Source'], 'xmltv');
expect(actions, isNot(contains('get_simple_data_table')));
});
test('la grille entière partage un seul téléchargement de la table',
() async {
// 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant
// de `get_live_streams` (7 Mo en prod) partaient en parallèle.
var liveStreamsCalls = 0;
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
if (action == 'get_live_streams') {
liveStreamsCalls++;
await Future<void>.delayed(const Duration(milliseconds: 50));
return http.Response(
jsonEncode([
for (var i = 1; i <= 5; i++)
{'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
]),
200,
);
}
return http.Response('{}', 200);
});
final api = apiWith(client);
final responses = await Future.wait([
for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'),
]);
expect(responses.every((r) => r.statusCode == 200), isTrue);
expect(liveStreamsCalls, 1);
});
});
}
-76
View File
@@ -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);
});
});
}
-33
View File
@@ -1,33 +0,0 @@
import 'package:test/test.dart';
import '../utils/log_redactor.dart';
void main() {
group('LogRedactor.redactUrl', () {
test('masks credentials in query strings', () {
final out = LogRedactor.redactUrl(
'http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams',
);
expect(out, isNot(contains('john')));
expect(out, isNot(contains('hunter2')));
expect(out, contains('username=***'));
expect(out, contains('password=***'));
expect(out, contains('action=get_live_streams'));
});
test('masks credentials in live/movie/series path segments', () {
for (final kind in ['live', 'movie', 'series']) {
final out = LogRedactor.redactUrl(
'http://srv:8080/$kind/john/hunter2/12345.ts',
);
expect(out, isNot(contains('john')), reason: kind);
expect(out, isNot(contains('hunter2')), reason: kind);
expect(out, contains('/$kind/***/***/12345.ts'), reason: kind);
}
});
test('leaves URLs without credentials untouched', () {
const url = 'http://srv:8080/images/logo.png';
expect(LogRedactor.redactUrl(url), url);
});
});
}
-128
View File
@@ -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);
});
});
}
-135
View File
@@ -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);
});
});
}
-37
View File
@@ -1,37 +0,0 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
import '../utils/password_hasher.dart';
void main() {
group('PasswordHasher', () {
test('bcrypt roundtrip verifies the original password', () {
final hash = PasswordHasher.hash('s3cret-Pass!');
expect(hash, startsWith(r'$2'));
expect(PasswordHasher.verify('s3cret-Pass!', hash), isTrue);
expect(PasswordHasher.verify('wrong', hash), isFalse);
});
test('legacy unsalted SHA-256 hashes still verify', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.verify('admin', legacy), isTrue);
expect(PasswordHasher.verify('not-admin', legacy), isFalse);
});
test('isLegacy detects SHA-256 hex digests only', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.isLegacy(legacy), isTrue);
expect(PasswordHasher.isLegacy(PasswordHasher.hash('admin')), isFalse);
});
test('rehash produces a non-legacy hash', () {
final rehashed = PasswordHasher.hash('admin');
expect(PasswordHasher.isLegacy(rehashed), isFalse);
expect(PasswordHasher.verify('admin', rehashed), isTrue);
});
test('malformed hash never verifies', () {
expect(PasswordHasher.verify('x', 'garbage-hash'), isFalse);
});
});
}
-47
View File
@@ -1,47 +0,0 @@
import 'package:test/test.dart';
import '../api/player_log.dart';
void main() {
group('formatPlayerEvent', () {
test('écrit une coupure avec sa durée et le buffer', () {
expect(
formatPlayerEvent({
'event': 'stall',
'session': 'ab12',
'ch': '554034',
'ms': 5230,
'buffer': 0.04,
}),
'[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0',
);
});
test('refuse un événement inconnu ou un payload invalide', () {
expect(formatPlayerEvent({'event': 'rm -rf'}), isNull);
expect(formatPlayerEvent('stall'), isNull);
expect(formatPlayerEvent(null), isNull);
});
test('pas d\'injection de lignes ni d\'URL (identifiants Xtream)', () {
final line = formatPlayerEvent({
'event': 'mpegts_error',
'detail': 'NetworkError http://panel/live/user/pass/1.ts\n[Player] faux',
})!;
expect(line, isNot(contains('\n')));
expect(line, isNot(contains('user/pass')));
expect(line, contains('<url>'));
});
test('ignore les champs inattendus et les nombres non finis', () {
final line = formatPlayerEvent({
'event': 'seek',
'from': 10,
'to': double.infinity,
'cookie': 'secret',
})!;
expect(line, contains('from=10'));
expect(line, isNot(contains('to=')));
expect(line, isNot(contains('secret')));
});
});
}
-245
View File
@@ -1,245 +0,0 @@
import 'package:test/test.dart';
import '../services/recording_decision.dart';
void main() {
final now = DateTime.utc(2026, 6, 10, 12, 0);
group('decideRecordingAction', () {
test('starts when due and capacity available', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
test('waits (keeps scheduled) when at capacity but window still open', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 2,
maxConcurrent: 2,
),
RecordingAction.wait,
);
});
test('fails when the end time has passed', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(hours: 2)),
endTime: now.subtract(const Duration(minutes: 5)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.fail,
);
});
test('does nothing before the start time', () {
expect(
decideRecordingAction(
now: now,
startTime: now.add(const Duration(minutes: 30)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.none,
);
});
test('second overlapping recording starts when maxConcurrent is 2', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 1,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
});
group('decideOrphanAction', () {
test('resumes while the window is still open', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(minutes: 30)),
hasFile: true,
),
OrphanAction.resume,
);
});
test('resumes even without a file (crash before the first byte)', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(minutes: 30)),
hasFile: false,
),
OrphanAction.resume,
);
});
test('keeps a partial file when the window has closed', () {
expect(
decideOrphanAction(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
hasFile: true,
),
OrphanAction.finish,
);
});
test('fails when the window has closed with nothing captured', () {
expect(
decideOrphanAction(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
hasFile: false,
),
OrphanAction.fail,
);
});
test('does not resume for the last seconds of the window', () {
expect(
decideOrphanAction(
now: now,
endTime: now.add(const Duration(seconds: 20)),
hasFile: true,
),
OrphanAction.finish,
);
});
});
group('decidePostExitAction', () {
test('retries when ffmpeg dies mid-window', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 1,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.retry,
);
});
test('retries on a clean exit too (upstream ended early)', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 0,
consecutiveFailures: 2,
hasFile: true,
),
PostExitAction.retry,
);
});
test('stops retrying once the attempt budget is spent', () {
expect(
decidePostExitAction(
now: now,
endTime: now.add(const Duration(minutes: 40)),
exitCode: 1,
consecutiveFailures: maxFfmpegAttempts - 1,
hasFile: true,
),
PostExitAction.complete,
);
});
test('completes at the end of the window', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 0,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.complete,
);
});
test('completes when ffmpeg errored but a file was captured', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 1,
consecutiveFailures: 0,
hasFile: true,
),
PostExitAction.complete,
);
});
test('fails when nothing was captured and the window is over', () {
expect(
decidePostExitAction(
now: now,
endTime: now,
exitCode: 1,
consecutiveFailures: 0,
hasFile: false,
),
PostExitAction.fail,
);
});
});
group('captureDuration', () {
test('uses the time left until the scheduled end, not the planned length',
() {
// Démarrage avec 2 minutes de retard sur une fenêtre d'une heure.
expect(
captureDuration(
now: now,
endTime: now.add(const Duration(minutes: 58)),
),
const Duration(minutes: 58),
);
});
test('never asks ffmpeg for a zero or negative duration', () {
expect(
captureDuration(
now: now,
endTime: now.subtract(const Duration(minutes: 5)),
),
const Duration(seconds: 30),
);
});
});
group('ffmpegRetryDelay', () {
test('backs off on repeated failures and caps at 30s', () {
expect(ffmpegRetryDelay(1), const Duration(seconds: 3));
expect(ffmpegRetryDelay(2), const Duration(seconds: 6));
expect(ffmpegRetryDelay(3), const Duration(seconds: 12));
expect(ffmpegRetryDelay(4), const Duration(seconds: 24));
expect(ffmpegRetryDelay(5), const Duration(seconds: 30));
expect(ffmpegRetryDelay(20), const Duration(seconds: 30));
});
});
}
-69
View File
@@ -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);
});
});
}
-40
View File
@@ -1,40 +0,0 @@
import 'package:test/test.dart';
import '../utils/safe_path.dart';
void main() {
group('SafePath.resolveWithin', () {
const base = '/app/recordings';
test('accepts files inside the base directory', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/show.log'),
isNotNull,
);
expect(SafePath.resolveWithin(base, 'show.log'), isNotNull);
expect(
SafePath.resolveWithin(base, '/app/recordings/sub/dir/file.log'),
isNotNull,
);
});
test('rejects .. traversal', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/../data/xtremflow.db'),
isNull,
);
expect(SafePath.resolveWithin(base, '../../etc/passwd'), isNull);
});
test('rejects absolute paths outside the base', () {
expect(SafePath.resolveWithin(base, '/etc/passwd'), isNull);
expect(SafePath.resolveWithin(base, '/app/data/xtremflow.db'), isNull);
});
test('rejects sibling directories with a shared prefix', () {
expect(
SafePath.resolveWithin(base, '/app/recordings-evil/file.log'),
isNull,
);
});
});
}
-29
View File
@@ -1,29 +0,0 @@
import 'package:test/test.dart';
import '../api/proxy_handler.dart';
void main() {
group('isForbiddenProxyHost', () {
test('blocks loopback', () {
expect(isForbiddenProxyHost('127.0.0.1'), isTrue);
expect(isForbiddenProxyHost('localhost'), isTrue);
expect(isForbiddenProxyHost('::1'), isTrue);
});
test('blocks private LAN ranges', () {
expect(isForbiddenProxyHost('10.0.0.5'), isTrue);
expect(isForbiddenProxyHost('172.16.0.1'), isTrue);
expect(isForbiddenProxyHost('172.31.255.255'), isTrue);
expect(isForbiddenProxyHost('192.168.1.10'), isTrue);
});
test('blocks link-local / cloud metadata range', () {
expect(isForbiddenProxyHost('169.254.169.254'), isTrue);
});
test('allows public IPs and hostnames', () {
expect(isForbiddenProxyHost('8.8.8.8'), isFalse);
expect(isForbiddenProxyHost('172.32.0.1'), isFalse);
expect(isForbiddenProxyHost('cdn.example.com'), isFalse);
});
});
}
-77
View File
@@ -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);
});
});
}
-62
View File
@@ -1,62 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
import '../api/upstream_probe.dart';
import '../models/playlist_config.dart';
void main() {
final playlist = PlaylistConfig(
id: 'p',
name: 'n',
dns: 'http://panel.test',
username: 'secretuser',
password: 'secretpass',
createdAt: DateTime(2026),
);
// Panneau simulé : redirige vers un serveur de diffusion avec jeton.
final client = MockClient.streaming((request, _) async {
final path = request.url.path;
if (request.url.host == 'panel.test') {
return http.StreamedResponse(
const Stream.empty(),
302,
headers: {
'location': 'http://lb.test$path?token=secrettoken',
},
);
}
if (path.endsWith('.m3u8')) {
return http.StreamedResponse(
Stream.value(utf8.encode(
'#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg1.ts\n'
'#EXTINF:6,\nseg2.ts\n',
)),
200,
);
}
return http.StreamedResponse(Stream.value(List.filled(2000, 0x47)), 200);
});
test('chronomètre .ts et .m3u8 en suivant les redirections', () async {
final result = await probeUpstream(playlist, '123', client: client);
final ts = result['ts'] as Map;
expect((ts['hops'] as List).map((h) => (h as Map)['status']), [302, 200]);
expect(ts['firstByteMs'], isNotNull);
final hls = result['m3u8'] as Map;
expect(hls['segments'], 2);
expect(hls['targetDuration'], '6');
expect((hls['segment'] as Map)['firstByteMs'], isNotNull);
});
test('aucun identifiant ni jeton dans la réponse', () async {
final json = jsonEncode(await probeUpstream(playlist, '123', client: client));
expect(json, isNot(contains('secretuser')));
expect(json, isNot(contains('secretpass')));
expect(json, isNot(contains('secrettoken')));
});
}
-164
View File
@@ -1,164 +0,0 @@
import 'dart:async';
import 'package:test/test.dart';
import '../models/playlist_config.dart';
import '../services/upstream_slots.dart';
void main() {
group('UpstreamSlots.makeRoom', () {
late UpstreamSlots slots;
late List<String> released;
var clock = DateTime(2026, 1, 1);
void add(String id, String account) {
clock = clock.add(const Duration(seconds: 1));
slots.register(id: id, account: account, release: () => released.add(id));
}
setUp(() {
slots = UpstreamSlots(now: () => clock);
released = [];
});
test('compte à 1 connexion : le flux précédent est coupé', () {
add('live_1_source', 'acc');
final freed = slots.makeRoom('acc', max: 1, keep: 'live_2_source');
expect(freed, ['live_1_source']);
expect(released, ['live_1_source']);
expect(slots.countFor('acc'), 0);
});
test('la session demandée elle-même n\'est jamais coupée', () {
add('live_1_source', 'acc');
expect(slots.makeRoom('acc', max: 1, keep: 'live_1_source'), isEmpty);
expect(released, isEmpty);
});
test('les plus anciennes partent d\'abord, dans la limite du quota', () {
add('a', 'acc');
add('b', 'acc');
add('c', 'acc');
// 3 connexions max : il faut une place pour la nouvelle → 1 seule coupée.
expect(slots.makeRoom('acc', max: 3, keep: 'new'), ['a']);
expect(slots.countFor('acc'), 2);
});
test('un autre compte n\'est jamais touché', () {
add('x', 'other');
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
expect(slots.countFor('other'), 1);
});
test('unregister avec un jeton périmé ne libère pas la session relancée', () {
final old = slots.register(id: 's', account: 'acc', release: () {});
slots.register(id: 's', account: 'acc', release: () {});
slots.unregister('s', token: old);
expect(slots.countFor('acc'), 1);
});
test('un flux encore regardé n\'est jamais coupé (pas de ping-pong entre deux lecteurs)', () {
slots.register(
id: 'watched',
account: 'acc',
release: () => released.add('watched'),
isActive: () => true,
);
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
expect(released, isEmpty);
expect(slots.countFor('acc'), 1);
});
test('seuls les orphelins partent, même plus récents qu\'un flux regardé', () {
slots.register(
id: 'watched',
account: 'acc',
release: () => released.add('watched'),
isActive: () => true,
);
add('orphan', 'acc');
expect(slots.makeRoom('acc', max: 1, keep: 'new'), ['orphan']);
expect(released, ['orphan']);
});
test('quota inconnu ou nul : rien n\'est coupé', () {
add('a', 'acc');
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
});
});
group('AccountLimits', () {
PlaylistConfig playlist() => PlaylistConfig(
id: 'p',
name: 'n',
dns: 'http://panel.test',
username: 'u',
password: 'x',
createdAt: DateTime(2026),
);
test('ne fait jamais attendre un flux : repli immédiat, valeur réelle ensuite', () async {
final pending = Completer<int?>();
final limits = AccountLimits(fetch: (_) => pending.future);
// Le panneau répond lentement (4 s mesurées en prod) : le premier flux
// part tout de suite sur la valeur de repli.
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
pending.complete(3);
await Future<void>.delayed(Duration.zero);
expect(await limits.maxFor(playlist()), 3);
});
test('valeur périmée servie pendant le rafraîchissement', () async {
var calls = 0;
final limits = AccountLimits(
ttl: Duration.zero,
fetch: (_) async => ++calls == 1 ? 2 : 5,
);
await limits.maxFor(playlist());
await Future<void>.delayed(Duration.zero);
// Périmée (ttl nul) : la valeur connue sort sans attendre la nouvelle.
expect(await limits.maxFor(playlist()), 2);
await Future<void>.delayed(Duration.zero);
expect(await limits.maxFor(playlist()), 5);
});
test('panneau en échec : repli, sans exception', () async {
final limits = AccountLimits(fetch: (_) async => throw Exception('down'));
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
await Future<void>.delayed(Duration.zero);
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
});
});
group('parseMaxConnections', () {
test('lit la valeur texte renvoyée par player_api', () {
expect(
parseMaxConnections({
'user_info': {'max_connections': '1'},
}),
1,
);
});
test('accepte un entier', () {
expect(
parseMaxConnections({
'user_info': {'max_connections': 3},
}),
3,
);
});
test('réponse inexploitable → null', () {
expect(parseMaxConnections({'user_info': {}}), isNull);
expect(parseMaxConnections('oops'), isNull);
expect(
parseMaxConnections({
'user_info': {'max_connections': '0'},
}),
isNull,
);
});
});
}
-23
View File
@@ -1,23 +0,0 @@
import 'package:test/test.dart';
import '../api/streaming_handler.dart';
void main() {
group('vodContainerExtension', () {
test('garde l\'extension réelle du catalogue', () {
expect(vodContainerExtension('mp4'), 'mp4');
expect(vodContainerExtension('MKV'), 'mkv');
expect(vodContainerExtension('avi'), 'avi');
});
test('ancien client sans ext : mkv, comme avant', () {
expect(vodContainerExtension(null), 'mkv');
expect(vodContainerExtension(''), 'mkv');
});
test('refuse tout ce qui pourrait sortir du nom de fichier', () {
expect(vodContainerExtension('mp4/../x'), 'mkv');
expect(vodContainerExtension('mp4?a=b'), 'mkv');
expect(vodContainerExtension('toolongext'), 'mkv');
});
});
}
@@ -1,75 +0,0 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
import '../services/xmltv_epg_service.dart';
/// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de
/// l'abonné y figurent en clair, comme en production.
const _panelUrl =
'http://panel.example:8080/xmltv.php?username=john&password=hunter2';
/// Exécute [body] en capturant tout ce qui passe par `print`.
Future<List<String>> _capturePrints(Future<void> Function() body) async {
final lines = <String>[];
await runZoned(
body,
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => lines.add(line),
),
);
return lines;
}
void main() {
group('XmltvEpgService — journalisation', () {
test('masque les identifiants de l\'URL quand la source répond', () async {
final service = XmltvEpgService(
sourceUrls: const [_panelUrl],
client: MockClient(
(_) async => http.Response('<?xml version="1.0"?><tv></tv>', 200),
),
);
final lines = await _capturePrints(service.ensureFresh);
final xmltvLines = lines.where((l) => l.startsWith('[XmltvEpg]'));
expect(xmltvLines, isNotEmpty);
for (final line in xmltvLines) {
expect(line, isNot(contains('john')));
expect(line, isNot(contains('hunter2')));
}
expect(
lines,
contains(contains('username=***&password=***')),
);
});
test('masque les identifiants de l\'URL et de l\'exception en cas d\'échec',
() async {
// `ClientException` recopie l'URL demandée dans son message : c'est
// par là que les identifiants fuyaient aussi.
final service = XmltvEpgService(
sourceUrls: const [_panelUrl],
client: MockClient(
(request) async => throw http.ClientException(
'Connection refused',
request.url,
),
),
);
final lines = await _capturePrints(service.ensureFresh);
final failure = lines.firstWhere(
(l) => l.contains('échec'),
orElse: () => fail('aucune ligne d\'échec journalisée : $lines'),
);
expect(failure, isNot(contains('john')));
expect(failure, isNot(contains('hunter2')));
expect(failure, contains('username=***&password=***'));
expect(failure, contains('Connection refused'));
});
});
}
-352
View File
@@ -1,352 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
import '../services/xmltv_epg_service.dart';
/// 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>
''';
}
String _stamp(DateTime d) {
final u = d.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year}${two(u.month)}${two(u.day)}'
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
}
void main() {
final service = XmltvEpgService(sourceUrls: const []);
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('nameKeys', () {
// Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas
// d'epg_channel_id, seul leur nom permet de retrouver le guide.
test('retire le préfixe pays et les marqueurs de qualité', () {
expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1'));
expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4'));
expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6'));
expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte'));
expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory'));
});
test('propose aussi la forme identifiant « nom.pays »', () {
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr'));
});
test('garde la variante décalée distincte de la chaîne principale', () {
expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1')));
});
test('ne réduit pas un nom court à rien', () {
expect(XmltvEpgService.nameKeys('M6'), contains('m6'));
expect(XmltvEpgService.nameKeys('HD'), isNot(contains('')));
});
});
group('correspondances élargies (chaînes vides mesurées en prod)', () {
XmltvEpgService serviceWith(String dump) => XmltvEpgService(
sourceUrls: const ['http://ext/epg.xml'],
client: MockClient((_) async => http.Response.bytes(
// Octets UTF-8 : les ids réels portent des accents.
const Utf8Encoder().convert(dump),
200,
)),
);
String channel(String id, String name, String title, DateTime start) => '''
<channel id="$id"><display-name>$name</display-name></channel>
<programme start="${_stamp(start)}" stop="${_stamp(start.add(const Duration(hours: 1)))}" channel="$id"><title>$title</title></programme>''';
test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - F3 ALPES HD');
expect(hits.single.title, 'JT Alpes');
});
test('« AB3 » trouve AB3.Channel.fr (clé souple)', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}</tv>');
final hits = await svc.programmesFor('AB3.fr', displayName: 'FR - AB3 FHD');
expect(hits.single.title, 'Série');
});
test('« TF1 +1 » reprend le guide de TF1 décalé d\'une heure', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('TF1.fr', 'TF1', 'Le 13h', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 +1 FHD');
expect(hits.single.title, 'Le 13h');
expect(hits.single.start, now.add(const Duration(hours: 1)).copyWith(microsecond: 0, millisecond: 0));
});
test('une chaîne +1 qui a son propre guide le garde', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>'
'${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}'
'${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - BOOMERANG +1');
expect(hits.single.title, 'Décalé');
});
});
group('programmesFor par nom', () {
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
() async {
final now = DateTime.now().toUtc();
String stamp(DateTime d) {
final u = d.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year}${two(u.month)}${two(u.day)}'
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
}
final dump = '''
<tv>
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
<programme start="${stamp(now)}" stop="${stamp(now.add(const Duration(hours: 1)))}" channel="TF1.fr">
<title>Le 13h</title>
</programme>
</tv>
''';
final svc = XmltvEpgService(
sourceUrls: const ['http://ext/epg.xml'],
client: MockClient((_) async => http.Response(dump, 200)),
);
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉');
expect(hits.single.title, 'Le 13h');
});
test('indexe chaque display-name, pas seulement le premier', () {
final now = DateTime.now().toUtc();
final index = service.parseForTest('''
<tv>
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
<programme start="${_stamp(now)}" stop="${_stamp(now.add(const Duration(hours: 1)))}" channel="x.fr"><title>T</title></programme>
</tv>
''');
expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull);
});
});
group('parseXmltvDate', () {
test('applique le décalage horaire annoncé', () {
expect(
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('réactivité du serveur', () {
test('l’indexation ne gèle pas la boucle d’événements', () async {
// Le serveur n'a qu'un isolate : décoder et parser un dump de
// plusieurs milliers de chaînes dessus bloquait tout le reste pendant
// des secondes — y compris le relais `turbo.ts` des flux en cours,
// d'où des coupures systématiques au démarrage de la lecture.
final now = DateTime.now().toUtc();
String stamp(DateTime d) {
String two(int v) => v.toString().padLeft(2, '0');
return '${d.year}${two(d.month)}${two(d.day)}'
'${two(d.hour)}${two(d.minute)}${two(d.second)} +0000';
}
// Volume d'un vrai dump de panneau : ~7000 chaînes, résumés de
// plusieurs phrases.
final desc = 'Résumé du programme, avec assez de texte pour peser '
'comme un vrai guide. ' * 4;
final xml = StringBuffer('<?xml version="1.0"?><tv>');
for (var c = 0; c < 7000; c++) {
xml.write('<channel id="Chaine.$c.fr">'
'<display-name>FR - CHAINE $c</display-name></channel>');
for (var p = 0; p < 30; p++) {
final start = now.add(Duration(minutes: 30 * p));
final stop = start.add(const Duration(minutes: 30));
xml.write('<programme start="${stamp(start)}" '
'stop="${stamp(stop)}" channel="Chaine.$c.fr">'
'<title>Programme $p</title><desc>$desc</desc>'
'</programme>');
}
}
xml.write('</tv>');
final body = xml.toString();
final service = XmltvEpgService(
sourceUrls: const ['http://dump.example/epg.xml'],
client: MockClient((_) async => http.Response(body, 200)),
);
// Un « battement » toutes les 10 ms : le plus long écart observé est
// le temps pendant lequel la boucle d'événements est restée bloquée.
var last = DateTime.now();
var worstGap = Duration.zero;
final heartbeat = Timer.periodic(const Duration(milliseconds: 10), (_) {
final now = DateTime.now();
final gap = now.difference(last);
if (gap > worstGap) worstGap = gap;
last = now;
});
await service.ensureFresh();
heartbeat.cancel();
// Le dernier blocage n'est suivi d'aucun battement : le compter aussi.
final tail = DateTime.now().difference(last);
if (tail > worstGap) worstGap = tail;
expect(service.channelCount, greaterThanOrEqualTo(7000));
expect(
worstGap,
lessThan(const Duration(milliseconds: 250)),
reason: 'boucle d’événements gelée ${worstGap.inMilliseconds} ms',
);
});
});
group('horizon', () {
test('écarte les programmes au-delà de l’horizon d’indexation', () {
// Un dump national couvre sept jours : tout garder ferait grossir
// l'index pour un guide qui n'affiche que le programme courant.
final service = XmltvEpgService(
sourceUrls: const [],
horizon: const Duration(hours: 48),
);
final far = DateTime.now().toUtc().add(const Duration(days: 5));
expect(
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
isEmpty,
);
});
});
}
-84
View File
@@ -1,84 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
/// Versionnage des assets non hachés de la build web.
///
/// POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
/// gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
/// `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
/// en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
/// l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
/// déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
/// chaque version a sa propre URL : aucun cache intermédiaire ne peut la
/// confondre avec la précédente.
/// Empreinte courte et stable de [bytes].
String contentVersion(List<int> bytes) =>
md5.convert(bytes).toString().substring(0, 12);
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
///
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
/// référence déjà versionnée restent intactes.
String versionAssetRefs(String content, Map<String, String> versions) {
var out = content;
versions.forEach((asset, version) {
final pattern = RegExp('(["\'])${RegExp.escape(asset)}\\1');
out = out.replaceAllMapped(
pattern,
(m) => '${m[1]}$asset?v=$version${m[1]}',
);
});
return out;
}
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
/// être servis à la place des fichiers d'origine.
///
/// Calculé une fois au démarrage : la build web est figée dans l'image.
Map<String, String> buildVersionedEntryPoints(String webPath) {
String? read(String name) {
final file = File('$webPath/$name');
return file.existsSync() ? file.readAsStringSync() : null;
}
String? versionOf(String name) {
final file = File('$webPath/$name');
return file.existsSync() ? contentVersion(file.readAsBytesSync()) : null;
}
final rewritten = <String, String>{};
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
// de main.dart.js, d'où une version calculée APRÈS réécriture.
final bootstrap = read('flutter_bootstrap.js');
final mainVersion = versionOf('main.dart.js');
if (bootstrap != null && mainVersion != null) {
final versioned =
versionAssetRefs(bootstrap, {'main.dart.js': mainVersion});
rewritten['flutter_bootstrap.js'] = versioned;
final index = read('index.html');
if (index != null) {
rewritten['index.html'] = versionAssetRefs(index, {
'flutter_bootstrap.js': contentVersion(utf8.encode(versioned)),
});
}
}
final playerAssets = <String, String>{
for (final name in const ['xf-player-core.js', 'xf-player.css'])
if (versionOf(name) case final v?) name: v,
};
for (final page in const [
'player.html',
'player_lite.html',
'player_mobile.html',
]) {
final html = read(page);
if (html != null) rewritten[page] = versionAssetRefs(html, playerAssets);
}
return rewritten;
}
-29
View File
@@ -1,29 +0,0 @@
/// Redacts IPTV credentials from URLs before they reach logs.
///
/// Xtream Codes embeds credentials both in query strings
/// (`?username=u&password=p`) and in path segments
/// (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
class LogRedactor {
static final RegExp _queryCreds = RegExp(
r'(username|password)=[^&\s]*',
caseSensitive: false,
);
static final RegExp _pathCreds = RegExp(
r'/(live|movie|series)/[^/\s]+/[^/\s]+/',
caseSensitive: false,
);
/// Returns [url] with credentials replaced by `***`.
static String redactUrl(String url) {
var redacted = url.replaceAllMapped(
_queryCreds,
(m) => '${m.group(1)}=***',
);
redacted = redacted.replaceAllMapped(
_pathCreds,
(m) => '/${m.group(1)}/***/***/',
);
return redacted;
}
}
-103
View File
@@ -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;
}
-35
View File
@@ -1,35 +0,0 @@
import 'dart:convert';
import 'package:bcrypt/bcrypt.dart';
import 'package:crypto/crypto.dart';
class PasswordHasher {
/// Cost factor 10: ~100-300ms per hash in pure Dart, acceptable for login frequency.
static const int _bcryptRounds = 10;
/// Legacy hashes are unsalted SHA-256 hex digests (64 hex chars).
static final RegExp _legacyPattern = RegExp(r'^[0-9a-f]{64}$');
/// Hash a password using bcrypt (salt embedded in the result).
static String hash(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptRounds));
}
/// Verify a password against a stored hash.
/// Supports legacy unsalted SHA-256 hashes for accounts created before
/// the bcrypt migration; those are rehashed lazily on successful login.
static bool verify(String password, String hash) {
if (isLegacy(hash)) {
final digest = sha256.convert(utf8.encode(password)).toString();
return digest == hash;
}
try {
return BCrypt.checkpw(password, hash);
} catch (_) {
// Malformed hash (neither legacy SHA-256 nor valid bcrypt)
return false;
}
}
/// True if the stored hash uses the legacy unsalted SHA-256 scheme.
static bool isLegacy(String hash) => _legacyPattern.hasMatch(hash);
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:path/path.dart' as p;
/// Path validation helpers to prevent directory traversal.
class SafePath {
/// Resolves [candidate] and returns it only if it stays inside [baseDir].
/// Returns null when the path escapes the base directory (e.g. via `..`
/// segments or an absolute path pointing elsewhere).
static String? resolveWithin(String baseDir, String candidate) {
final base = p.normalize(p.absolute(baseDir));
final resolved = p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(base, candidate),
);
if (resolved == base || p.isWithin(base, resolved)) {
return resolved;
}
return null;
}
}
-49
View File
@@ -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
View File
@@ -17,10 +17,11 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Flutter build failed"; exit 1 }
Write-Host "📂 Copying Web Assets..."
Copy-Item -Recurse "build\web" "dist\web"
# 4. Compile Dart Server
# 4. Compile Rust Server
Write-Host "🔨 Compiling Backend Server..." -ForegroundColor Yellow
dart compile exe bin/server.dart -o dist/xtremflow_server.exe
if ($LASTEXITCODE -ne 0) { Write-Error "Dart compile failed"; exit 1 }
cargo build --release --manifest-path server/Cargo.toml
if ($LASTEXITCODE -ne 0) { Write-Error "Rust compile failed"; exit 1 }
Copy-Item "server\target\release\xtremflow-server.exe" "dist\xtremflow_server.exe"
# 5. Create Start Script
Write-Host "📝 Creating Launcher Script..."
+2063
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "xtremflow-server"
version = "0.1.0"
edition = "2024"
description = "Serveur XtremFlow : API, proxy Xtream, streaming FFmpeg, enregistrements TV"
publish = false
[dependencies]
async-stream = "0.3.6"
axum = "0.8.9"
base64 = "0.22"
bcrypt = "0.19.3"
bytes = "1.12.1"
chrono = "0.4.45"
flate2 = "1.1.10"
futures = "0.3.34"
libc = "0.2.190"
md-5 = "0.10"
mime_guess = "2.0.5"
percent-encoding = "2.3.2"
quick-xml = "0.37"
regex = "1.13.1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.10"
tokio = { version = "1.53.2", features = ["full"] }
tokio-util = { version = "0.7.20", features = ["io"] }
url = "2.5.8"
uuid = { version = "1.28.0", features = ["v4"] }
[profile.release]
lto = "thin"
strip = true
+384
View File
@@ -0,0 +1,384 @@
//! Comptes : authentification, utilisateurs (admin), playlists, réglages.
use crate::http::{self, Req, Resp, extract_token, json, json_as_text, segments, text};
use crate::state::SharedState;
use crate::util::password;
use serde_json::{Value, json};
fn error_json(status: u16, error: impl Into<String>) -> Resp {
json(status, &json!({"success": false, "error": error.into()}))
}
fn str_field(payload: &Value, key: &str) -> Option<String> {
payload.get(key).and_then(Value::as_str).map(str::to_string)
}
/// Corps JSON objet, ou réponse 500 (comportement de l'ancien serveur sur
/// un corps illisible).
fn parse_object(req: &Req) -> Result<Value, Resp> {
match serde_json::from_slice::<Value>(&req.body) {
Ok(v) if v.is_object() => Ok(v),
Ok(_) => Err(error_json(
500,
"type is not a subtype of type 'Map<String, dynamic>'",
)),
Err(e) => Err(error_json(500, format!("FormatException: {e}"))),
}
}
/// Longueur à la manière de Dart (unités UTF-16).
fn dart_len(s: &str) -> usize {
s.encode_utf16().count()
}
// ==================== /api/auth ====================
pub async fn auth(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["login"]) => login(state, req).await,
("POST", ["logout"]) => logout(state, req),
("GET" | "HEAD", ["me"]) => me(state, req),
_ => http::not_found(),
}
}
async fn login(state: &SharedState, req: &Req) -> Resp {
println!("[Auth] Login attempt received");
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => {
println!("[Auth] ERROR during login: invalid body");
return resp;
}
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
println!("[Auth] Missing username or password");
return error_json(400, "Username and password are required");
};
println!("[Auth] Verifying credentials for user: {username}");
// bcrypt est coûteux : hors du runtime asynchrone.
let db = state.db.clone();
let user_name = username.clone();
let user = tokio::task::spawn_blocking(move || db.verify_credentials(&user_name, &pass))
.await
.ok()
.flatten();
let Some(user) = user else {
println!("[Auth] Invalid credentials for user: {username}");
return error_json(401, "Invalid credentials");
};
println!(
"[Auth] User verified, creating session for userId: {}",
user.id
);
let session = match state.db.create_session(&user.id) {
Ok(s) => s,
Err(e) => return error_json(500, e.to_string()),
};
// Cookie de session HttpOnly : les requêtes média de même origine
// (hls.js dans l'iframe du lecteur ne peut pas envoyer d'en-tête
// Authorization) sont ainsi authentifiées.
let max_age = (session.expires_at.to_utc() - chrono::Utc::now()).num_seconds();
let mut resp = json(
200,
&json!({"success": true, "user": user.to_json(), "token": session.token}),
);
http::set_header(
&mut resp,
"set-cookie",
&format!(
"session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}",
session.token
),
);
resp
}
fn logout(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
state.db.delete_session(&token);
let mut resp = json(200, &json!({"success": true}));
http::set_header(
&mut resp,
"set-cookie",
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
resp
}
fn me(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
let Some(session) = state.db.find_session_by_token(&token) else {
return error_json(401, "Invalid or expired session");
};
match state.db.find_user_by_id(&session.user_id) {
Some(user) => json(200, &json!({"user": user.to_json()})),
None => error_json(404, "User not found"),
}
}
// ==================== /api/users ====================
fn is_admin(state: &SharedState, req: &Req) -> bool {
req.user_id
.as_deref()
.and_then(|id| state.db.find_user_by_id(id))
.is_some_and(|u| u.is_admin)
}
fn admin_required() -> Resp {
json_as_text(403, &json!({"success": false, "error": "Admin required"}))
}
pub async fn users(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list_users(state, req),
("POST", []) => create_user(state, req).await,
("PUT", [id, "password"]) => update_password(state, req, id).await,
("PUT", [id]) => update_user(state, req, id),
("DELETE", [id]) => delete_user(state, req, id),
_ => http::not_found(),
}
}
fn list_users(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let users: Vec<Value> = state
.db
.get_all_users()
.iter()
.map(|u| u.to_json())
.collect();
json(200, &json!({"success": true, "users": users}))
}
async fn create_user(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
return error_json(400, "Missing required fields");
};
let admin = payload
.get("isAdmin")
.and_then(Value::as_bool)
.unwrap_or(false);
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
if state.db.find_user_by_username(&username).is_some() {
return error_json(400, "Username already exists");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
match state.db.create_user(&username, &hash, admin) {
Ok(user) => json(200, &json!({"success": true, "user": user.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
async fn update_password(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let Some(pass) = str_field(&payload, "password") else {
return error_json(400, "Missing password");
};
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
state.db.update_password_hash(id, &hash);
json(200, &json!({"success": true}))
}
fn update_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
if let Some(admin) = payload.get("isAdmin").and_then(Value::as_bool)
&& let Err(e) = state.db.update_user_admin_status(id, admin)
{
return error_json(500, e.to_string());
}
json(200, &json!({"success": true}))
}
fn delete_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
// Interdit de se supprimer soi-même.
if req.user_id.as_deref() == Some(id) {
return error_json(400, "Cannot delete yourself");
}
match state.db.delete_user(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/playlists ====================
pub fn playlists(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return error_json(401, "Unauthorized");
};
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let list: Vec<Value> = state
.db
.get_playlists(&user_id)
.iter()
.map(|p| p.to_json())
.collect();
json(200, &json!({"playlists": list}))
}
("POST", []) => create_playlist(state, req, &user_id),
("PUT", [id]) => update_playlist(state, req, &user_id, id),
("DELETE", [id]) => delete_playlist(state, &user_id, id),
_ => http::not_found(),
}
}
fn create_playlist(state: &SharedState, req: &Req, user_id: &str) -> Resp {
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let (Some(name), Some(server_url), Some(username), Some(pass)) = (
field("name"),
field("serverUrl"),
field("username"),
field("password"),
) else {
return error_json(400, "Missing required fields");
};
let dns = field("dns").unwrap_or_else(|| server_url.clone());
match state
.db
.create_playlist(user_id, &name, &server_url, &username, &pass, Some(&dns))
{
Ok(p) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
fn update_playlist(state: &SharedState, req: &Req, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let name = field("name").unwrap_or(existing.name.clone());
let server_url = field("serverUrl").unwrap_or(existing.server_url.clone());
let username = field("username").unwrap_or(existing.username.clone());
// Mot de passe vide = « inchangé » : les clients ne reçoivent jamais le
// vrai mot de passe, les formulaires d'édition le renvoient vide.
let pass = field("password")
.filter(|p| !p.is_empty())
.unwrap_or(existing.password.clone());
let dns = field("dns").or(existing.dns.clone());
match state
.db
.update_playlist(id, &name, &server_url, &username, &pass, dns.as_deref())
{
Ok(Some(p)) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Ok(None) => error_json(404, "Playlist not found"),
Err(e) => error_json(500, e.to_string()),
}
}
fn delete_playlist(state: &SharedState, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
match state.db.delete_playlist(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/settings ====================
pub fn settings(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return json_as_text(403, &json!({"error": "Authentication required"}));
};
if !segments(rest).is_empty() {
return http::not_found();
}
match req.method.as_str() {
"GET" | "HEAD" => {
let settings = state
.db
.get_user_settings(&user_id)
.unwrap_or_else(|| "{}".to_string());
http::with_headers(
http::response(200, settings),
&[("content-type", "application/json")],
)
}
"POST" => {
let body = req.body_str();
if serde_json::from_str::<Value>(&body).is_err() {
return json_as_text(400, &json!({"error": "Invalid JSON"}));
}
if let Err(e) = state.db.update_user_settings(&user_id, &body) {
return text(
500,
json!({"error": format!("Failed to save settings: {e}")}).to_string(),
);
}
http::with_headers(
http::response(200, body),
&[("content-type", "application/json")],
)
}
_ => http::not_found(),
}
}
+194
View File
@@ -0,0 +1,194 @@
//! `/api/admin` : purge des temporaires et diagnostic du délai de zap.
use crate::http::{self, Req, Resp, json_as_text, segments};
use crate::models::PlaylistConfig;
use crate::state::SharedState;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
pub async fn admin(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["purge"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
http::json(200, &state.cleanup.run_cleanup().await)
}
("GET" | "HEAD", ["upstream-probe"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
let stream = req.query_param("stream").unwrap_or_default();
if stream.is_empty() || !stream.bytes().all(|b| b.is_ascii_digit()) {
return http::text(400, "stream=<id numérique> requis");
}
let Some(playlist) = state.get_playlist(req) else {
return http::text(403, "No playlist");
};
http::json(
200,
&probe_upstream(&state.http_manual, &playlist, &stream).await,
)
}
_ => http::not_found(),
}
}
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Reste à savoir lequel des deux formats
/// démarre le plus vite, et où part le temps. Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
///
/// [client] ne doit PAS suivre les redirections (chaque saut est chronométré).
pub async fn probe_upstream(
client: &reqwest::Client,
playlist: &PlaylistConfig,
stream_id: &str,
) -> Value {
let base = format!(
"{}/live/{}/{}/{stream_id}",
playlist.dns, playlist.username, playlist.password
);
let ts = match reqwest::Url::parse(&format!("{base}.ts")) {
Ok(url) => probe_stream(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
// Laisser le panneau libérer la connexion (comptes à une connexion).
tokio::time::sleep(Duration::from_secs(2)).await;
let hls = match reqwest::Url::parse(&format!("{base}.m3u8")) {
Ok(url) => probe_hls(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
json!({"stream": stream_id, "ts": ts, "m3u8": hls})
}
/// Suit les redirections à la main pour chronométrer chaque saut.
async fn open(
client: &reqwest::Client,
url: reqwest::Url,
started: Instant,
) -> Result<(Option<reqwest::Response>, Vec<Value>, reqwest::Url), String> {
let mut hops = Vec::new();
let mut current = url;
for _ in 0..5 {
let send = client
.get(current.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(20), send)
.await
.map_err(|_| "TimeoutException".to_string())?
.map_err(|_| "ClientException".to_string())?;
let status = response.status().as_u16();
hops.push(json!({
"host": current.host_str().unwrap_or(""),
"status": status,
"headersMs": started.elapsed().as_millis() as u64,
}));
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let (true, Some(location)) = ([301, 302, 303, 307, 308].contains(&status), location) {
drop(response);
current = current
.join(&location)
.map_err(|_| "FormatException".to_string())?;
continue;
}
return Ok((Some(response), hops, current));
}
Ok((None, hops, current))
}
fn status_error(response: Option<&reqwest::Response>) -> String {
match response {
Some(r) => format!("status {}", r.status().as_u16()),
None => "status null".to_string(),
}
}
async fn probe_stream(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, _) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let mut response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let mut bytes = 0usize;
let mut first_byte_ms: Option<u64> = None;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
let read = async {
while let Ok(Some(chunk)) = response.chunk().await {
first_byte_ms.get_or_insert(started.elapsed().as_millis() as u64);
bytes += chunk.len();
if bytes > 1_000_000 {
break;
}
}
};
let _ = tokio::time::timeout(Duration::from_secs(15), read).await;
json!({
"hops": hops,
"firstByteMs": first_byte_ms,
"firstMegabyteMs": (bytes > 1_000_000).then(|| started.elapsed().as_millis() as u64),
})
}
async fn probe_hls(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, final_url) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let body = match response.text().await {
Ok(b) => b,
Err(_) => {
return json!({"error": "ClientException", "afterMs": started.elapsed().as_millis() as u64});
}
};
let playlist_ms = started.elapsed().as_millis() as u64;
let lines: Vec<&str> = body.lines().collect();
let segments: Vec<&str> = lines
.iter()
.copied()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let target = lines
.iter()
.find(|l| l.starts_with("#EXT-X-TARGETDURATION"))
.map(|l| l.rsplit(':').next().unwrap_or("").to_string())
.unwrap_or_default();
if segments.is_empty() {
return json!({"hops": hops, "playlistMs": playlist_ms, "segments": 0});
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
let pick = if segments.len() >= 2 {
segments[segments.len() - 2]
} else {
segments[segments.len() - 1]
};
let segment = match final_url.join(pick) {
Ok(url) => Box::pin(probe_stream(client, url)).await,
Err(_) => json!({"error": "FormatException"}),
};
json!({
"hops": hops,
"playlistMs": playlist_ms,
"segments": segments.len(),
"targetDuration": target,
"segment": segment,
"segmentStartedAtMs": playlist_ms,
})
}
+774
View File
@@ -0,0 +1,774 @@
//! API EPG — `GET /api/epg/<channel_id>`, cache 30 minutes.
//!
//! Le panneau de l'abonné reste la source de référence, mais par son dump
//! `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
//! `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
//! ensuite la source XMLTV externe (déjà indexée en mémoire), puis
//! l'interrogation chaîne par chaîne — cas des revendeurs dont le guide est
//! figé depuis plusieurs jours.
use crate::dart_time::{DartDateTime, utc_iso};
use crate::http::{self, Resp};
use crate::models::PlaylistConfig;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::util::b64;
use crate::util::log_redactor::redact_url;
use chrono::{TimeZone, Utc};
use futures::FutureExt;
use futures::future::{BoxFuture, Shared};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Send + Sync>;
/// Correspondance `stream_id` → identifiant EPG et nom, par compte. Le dump
/// XMLTV indexe par identifiant de chaîne, pas par `stream_id` du panneau.
#[derive(Default)]
struct ChannelMap {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
expires_at: Option<Instant>,
}
struct Inner {
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
/// « dns|username|channelId » → réponse JSON. La clé inclut le compte :
/// deux panneaux peuvent partager un même `stream_id`.
cache: Mutex<HashMap<String, (String, Instant)>>,
channel_maps: Mutex<HashMap<String, Arc<ChannelMap>>>,
channel_maps_in_flight: Mutex<HashMap<String, Shared<BoxFuture<'static, Arc<ChannelMap>>>>>,
panel_xmltv: Mutex<HashMap<String, Arc<XmltvEpgService>>>,
}
#[derive(Clone)]
pub struct EpgApi {
inner: Arc<Inner>,
}
/// Dump XMLTV servi par le panneau lui-même.
///
/// POURQUOI : `get_simple_data_table` répond en plusieurs secondes chez
/// beaucoup de revendeurs, et il faut un appel par chaîne — une grille de
/// trente chaînes demandait plus d'une minute. `xmltv.php` renvoie le guide
/// de toutes les chaînes en une requête, réutilisée pendant des heures.
pub fn default_panel_xmltv(client: reqwest::Client) -> PanelXmltvBuilder {
Arc::new(move |playlist: &PlaylistConfig| {
let enc = |s: &str| url::form_urlencoded::byte_serialize(s.as_bytes()).collect::<String>();
let mut config = XmltvConfig::new(vec![format!(
"{}/xmltv.php?username={}&password={}",
playlist.dns,
enc(&playlist.username),
enc(&playlist.password)
)]);
config.refresh_interval = Duration::from_secs(3 * 3600);
// Guide facultatif : pas plusieurs minutes d'attente sur un panneau lent.
config.download_timeout = Duration::from_secs(90);
XmltvEpgService::new(config, client.clone())
})
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`.
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
let mut epg_ids = HashMap::new();
let mut names = HashMap::new();
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
let text = String::from_utf8_lossy(body);
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&text) {
for item in items {
let Value::Object(item) = item else { continue };
let Some(stream_id) = item.get("stream_id").and_then(value_to_string) else {
continue;
};
if stream_id.is_empty() {
continue;
}
if let Some(epg_id) = item.get("epg_channel_id").and_then(value_to_string)
&& !epg_id.is_empty()
{
epg_ids.insert(stream_id.clone(), epg_id);
}
if let Some(name) = item.get("name").and_then(value_to_string)
&& !name.is_empty()
{
names.insert(stream_id, name);
}
}
}
(epg_ids, names)
}
/// Epoch (secondes, texte ou entier) → ISO-8601 UTC.
fn iso_utc(timestamp: Option<&Value>) -> Option<String> {
let seconds: i64 = match timestamp? {
Value::Number(n) => n.as_i64()?,
Value::String(s) => s.parse().ok()?,
_ => return None,
};
if seconds <= 0 {
return None;
}
Utc.timestamp_opt(seconds, 0).single().map(utc_iso)
}
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
fn normalize_date(raw: &str) -> String {
if raw.contains(' ') && !raw.contains('T') {
raw.replacen(' ', "T", 1)
} else {
raw.to_string()
}
}
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi.
pub fn transform_epg_data(raw: &Value, channel_id: &str) -> Value {
let listings: Vec<Value> = match raw {
Value::Object(map) if map.contains_key("epg_listings") => map
.get("epg_listings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Value::Array(list) => list.clone(),
_ => Vec::new(),
};
let str_of = |item: &Map<String, Value>, key: &str| -> String {
item.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let mut programmes = Vec::new();
for item in listings {
let Value::Object(item) = item else {
return json!({"channel_id": channel_id, "programmes": [], "error": "EPG indisponible"});
};
// Les champs texte `start`/`end` sont dans le fuseau du panneau, sans
// indicateur de zone : le client les relisait en heure locale et
// décalait tout le guide. Les `*_timestamp` sont de l'epoch UTC.
let start = iso_utc(item.get("start_timestamp"))
.unwrap_or_else(|| normalize_date(&str_of(&item, "start")));
let end = iso_utc(item.get("stop_timestamp"))
.or_else(|| iso_utc(item.get("end_timestamp")))
.unwrap_or_else(|| {
let raw = item
.get("stop")
.and_then(Value::as_str)
.or_else(|| item.get("end").and_then(Value::as_str))
.unwrap_or("");
normalize_date(raw)
});
let decode = |raw: String| -> String {
if raw.is_empty() {
return raw;
}
match b64::decode_text(&raw) {
Some(decoded) if !decoded.is_empty() => decoded,
_ => raw,
}
};
programmes.push(json!({
"title": decode(str_of(&item, "title")),
"description": decode(str_of(&item, "description")),
"start": start,
"end": end,
"channel_id": channel_id,
}));
}
json!({"channel_id": channel_id, "programmes": programmes})
}
/// Le guide contient-il un programme en cours ou à venir ? Un panneau au
/// guide figé répond avec des centaines de programmes tous terminés :
/// compter les entrées ne suffit pas.
fn has_current_programme(programmes: &[Value]) -> bool {
let now = Utc::now();
programmes.iter().any(|p| {
p.get("end")
.and_then(Value::as_str)
.and_then(DartDateTime::parse)
.is_some_and(|end| end.to_utc() > now)
})
}
impl EpgApi {
pub fn new(
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
) -> Self {
Self {
inner: Arc::new(Inner {
xmltv,
http,
panel_builder,
cache: Mutex::default(),
channel_maps: Mutex::default(),
channel_maps_in_flight: Mutex::default(),
panel_xmltv: Mutex::default(),
}),
}
}
pub async fn handle(&self, playlist: Option<PlaylistConfig>, channel_id: &str) -> Resp {
let Some(playlist) = playlist else {
return http::json(403, &json!({"error": "Playlist non trouvée"}));
};
let cache_key = format!("{}|{}|{channel_id}", playlist.dns, playlist.username);
if let Some((data, expires)) = self.inner.cache.lock().unwrap().get(&cache_key)
&& Instant::now() < *expires
{
return http::with_headers(
http::response(200, data.clone()),
&[("content-type", "application/json"), ("x-cache", "HIT")],
);
}
// Sources par ordre de préférence. On s'arrête à la première qui
// contient un programme en cours ou à venir ; à défaut, la première
// non vide sert de repli.
let mut programmes: Vec<Value> = Vec::new();
let mut source = "xtream";
let mut found = false;
for name in ["panel-xmltv", "xmltv", "xtream"] {
let candidate = match name {
"panel-xmltv" => self.panel_programmes(&playlist, channel_id).await,
"xmltv" => {
self.programmes_from_xmltv(self.inner.xmltv.clone(), &playlist, channel_id)
.await
}
_ => self.xtream_programmes(&playlist, channel_id).await,
};
if candidate.is_empty() {
continue;
}
let current = has_current_programme(&candidate);
if !found || current {
programmes = candidate;
source = name;
found = true;
}
if current {
break;
}
}
let body = json!({"channel_id": channel_id, "programmes": programmes}).to_string();
// 30 min pour un guide trouvé ; 5 min pour un guide vide, qui vient
// souvent d'une source momentanément indisponible.
let ttl = Duration::from_secs(if found { 30 * 60 } else { 5 * 60 });
self.inner
.cache
.lock()
.unwrap()
.insert(cache_key, (body.clone(), Instant::now() + ttl));
http::with_headers(
http::response(200, body),
&[
("content-type", "application/json"),
("x-cache", "MISS"),
("x-epg-source", source),
],
)
}
async fn panel_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
let key = format!("{}|{}", playlist.dns, playlist.username);
let service = self
.inner
.panel_xmltv
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| Arc::new((self.inner.panel_builder)(playlist)))
.clone();
self.programmes_from_xmltv(Some(service), playlist, channel_id)
.await
}
/// Programmes d'une chaîne dans un index XMLTV (panneau ou externe).
async fn programmes_from_xmltv(
&self,
xmltv: Option<Arc<XmltvEpgService>>,
playlist: &PlaylistConfig,
channel_id: &str,
) -> Vec<Value> {
let Some(xmltv) = xmltv else {
return Vec::new();
};
let channels = self.channel_map_for(playlist).await;
let epg_id = channels.epg_ids.get(channel_id).map(String::as_str);
let name = channels.names.get(channel_id).map(String::as_str);
if epg_id.is_none() && name.is_none() {
return Vec::new();
}
let now = Utc::now();
xmltv
.programmes_for(epg_id, name)
.await
.iter()
.filter(|p| p.stop > now)
.take(64)
.map(|p| p.to_json(channel_id))
.collect()
}
/// Interrogation du panneau chaîne par chaîne : chemin lent, plusieurs
/// secondes par action, réservé aux chaînes absentes des dumps.
async fn xtream_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
// `get_simple_data_table` d'abord : seule action réellement
// universelle. `get_epg` manque sur beaucoup de panneaux, qui
// renvoient alors le payload d'authentification.
for action in ["get_simple_data_table", "get_short_epg"] {
let url = format!(
"{}/player_api.php?username={}&password={}&action={action}&stream_id={channel_id}",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = self.inner.http.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (status, body) = match tokio::time::timeout(Duration::from_secs(20), fetch).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
// Une source parmi d'autres, pas une raison de répondre 500.
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : {}",
redact_url(&format!("{e:?}"))
);
continue;
}
Err(_) => {
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : TimeoutException after 0:00:20.000000"
);
continue;
}
};
if status != 200 {
continue;
}
let Ok(raw) = serde_json::from_str::<Value>(&String::from_utf8_lossy(&body)) else {
continue;
};
let programmes: Vec<Value> = transform_epg_data(&raw, channel_id)["programmes"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(Value::is_object)
.collect();
if !programmes.is_empty() {
return programmes;
}
}
Vec::new()
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures. Les
/// requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup (7 Mo de table en prod).
async fn channel_map_for(&self, playlist: &PlaylistConfig) -> Arc<ChannelMap> {
let key = format!("{}|{}", playlist.dns, playlist.username);
if let Some(map) = self.inner.channel_maps.lock().unwrap().get(&key)
&& map.expires_at.is_some_and(|e| Instant::now() < e)
{
return map.clone();
}
let shared = {
let mut in_flight = self.inner.channel_maps_in_flight.lock().unwrap();
match in_flight.get(&key) {
Some(f) => f.clone(),
None => {
let inner = self.inner.clone();
let playlist = playlist.clone();
let task_key = key.clone();
// Tâche détachée : le téléchargement aboutit même si le
// client qui l'a déclenché est parti.
let handle = tokio::spawn(async move {
let map = Arc::new(load_channel_map(&inner.http, &playlist).await);
inner
.channel_maps
.lock()
.unwrap()
.insert(task_key.clone(), map.clone());
inner
.channel_maps_in_flight
.lock()
.unwrap()
.remove(&task_key);
map
});
let f = async move { handle.await.unwrap_or_default() }
.boxed()
.shared();
in_flight.insert(key, f.clone());
f
}
}
};
shared.await
}
}
async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -> ChannelMap {
let url = format!(
"{}/player_api.php?username={}&password={}&action=get_live_streams",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = client.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new());
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
Ok(Ok((200, body))) => {
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
// des flux en cours.
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
Ok(parsed) => (epg_ids, names) = parsed,
Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"),
}
}
Ok(Ok(_)) => {}
Ok(Err(e)) => println!(
"[EpgApi] table des chaînes indisponible : {}",
redact_url(&format!("{e:?}"))
),
Err(_) => println!(
"[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000"
),
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
let ttl = if names.is_empty() {
Duration::from_secs(120)
} else {
Duration::from_secs(6 * 3600)
};
ChannelMap {
epg_ids,
names,
expires_at: Some(Instant::now() + ttl),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{Query, Request};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn convertit_les_epoch_en_utc_et_decode_le_base64() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"end": "2026-08-11 20:35:00",
"description": "TCdpbmZvIGR1IHNvaXI=",
"start_timestamp": "1786471200",
"stop_timestamp": "1786473300",
}]});
let result = transform_epg_data(&raw, "845452");
let p = &result["programmes"][0];
assert_eq!(p["start"], "2026-08-11T18:00:00.000Z");
assert_eq!(p["end"], "2026-08-11T18:35:00.000Z");
assert_eq!(p["title"], "Journal de 20h");
assert_eq!(p["description"], "L'info du soir");
assert_eq!(p["channel_id"], "845452");
}
#[test]
fn retombe_sur_les_champs_texte() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"stop": "2026-08-11 20:35:00",
}]});
let p = &transform_epg_data(&raw, "845452")["programmes"][0];
assert_eq!(p["start"], "2026-08-11T20:00:00");
assert_eq!(p["end"], "2026-08-11T20:35:00");
}
#[test]
fn liste_vide_sans_epg_listings() {
let raw = json!({"user_info": {"auth": 1}, "server_info": {"url": "x"}});
assert_eq!(transform_epg_data(&raw, "1")["programmes"], json!([]));
}
// ---------- handle() contre un faux panneau ----------
type Handler = Arc<dyn Fn(&str, &HashMap<String, String>) -> (u16, String) + Send + Sync>;
async fn mock(handler: Handler) -> String {
let app = axum::Router::new().fallback(
move |Query(q): Query<HashMap<String, String>>, req: Request| {
let handler = handler.clone();
async move {
// Laisser respirer les requêtes concurrentes (mutualisation).
tokio::time::sleep(Duration::from_millis(30)).await;
let (status, body) = handler(req.uri().path(), &q);
(axum::http::StatusCode::from_u16(status).unwrap(), body)
}
},
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
fn stamp(d: chrono::DateTime<Utc>) -> String {
d.format("%Y%m%d%H%M%S +0000").to_string()
}
fn dump(
id: &str,
name: &str,
start: chrono::DateTime<Utc>,
stop: chrono::DateTime<Utc>,
) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?><tv><channel id="{id}"><display-name>{name}</display-name></channel><programme start="{}" stop="{}" channel="{id}"><title lang="fr">Journal de 20h</title><desc lang="fr">L'info du soir</desc></programme></tv>"#,
stamp(start),
stamp(stop)
)
}
fn playlist(base: &str) -> PlaylistConfig {
PlaylistConfig {
dns: base.to_string(),
username: "u".into(),
password: "p".into(),
}
}
fn api(base: &str, external: bool) -> EpgApi {
let client = reqwest::Client::new();
let xmltv = external.then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(vec![format!("{base}/ext/epg.xml")]),
client.clone(),
))
});
let c = client.clone();
let builder: PanelXmltvBuilder = Arc::new(move |p: &PlaylistConfig| {
XmltvEpgService::new(
XmltvConfig::new(vec![format!("{}/xmltv.php", p.dns)]),
c.clone(),
)
});
EpgApi::new(xmltv, client, builder)
}
async fn body_json(resp: Resp) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn sert_le_dump_du_panneau_sans_appel_par_chaine() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, dump("France2.fr", "FRANCE 2", now - chrono::Duration::minutes(5), now + chrono::Duration::minutes(25)));
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (200, json!([{"stream_id": 845452, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}]).to_string());
}
(200, "{}".into())
}))
.await;
let resp = api(&base, false)
.handle(Some(playlist(&base)), "845452")
.await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
let body = body_json(resp).await;
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]);
}
#[tokio::test]
async fn retombe_sur_player_api_quand_le_dump_ne_couvre_pas_la_chaine() {
let future = chrono::Local::now() + chrono::Duration::minutes(10);
let panel_stamp =
|d: chrono::DateTime<chrono::Local>| d.format("%Y-%m-%d %H:%M:%S").to_string();
let start = panel_stamp(future);
let end = panel_stamp(future + chrono::Duration::hours(2));
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
match q.get("action").map(String::as_str) {
Some("get_live_streams") => (200, "[]".into()),
Some("get_simple_data_table") => (
200,
json!({"epg_listings": [{"title": "TWF0Y2g=", "description": "", "start": start, "end": end}]}).to_string(),
),
_ => (200, "{}".into()),
}
}))
.await;
let resp = api(&base, false).handle(Some(playlist(&base)), "999").await;
assert_eq!(resp.headers()["x-epg-source"], "xtream");
assert_eq!(body_json(resp).await["programmes"][0]["title"], "Match");
}
#[tokio::test]
async fn un_panneau_qui_plante_ne_prive_pas_du_dump_externe() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 1, "name": "FR - TF1 FHD ◉", "epg_channel_id": ""}])
.to_string(),
);
}
(500, "boom".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert_eq!(
body_json(resp).await["programmes"][0]["title"],
"Journal de 20h"
);
}
#[tokio::test]
async fn le_dump_externe_passe_avant_l_appel_lent() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (
200,
json!([{"stream_id": 1, "name": "TF1", "epg_channel_id": "TF1.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert!(
!actions
.lock()
.unwrap()
.contains(&"get_simple_data_table".to_string())
);
}
#[tokio::test]
async fn la_grille_partage_un_seul_telechargement_de_la_table() {
let now = Utc::now();
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
c.fetch_add(1, Ordering::SeqCst);
let list: Vec<Value> = (1..=5)
.map(|i| json!({"stream_id": i, "name": "TF1", "epg_channel_id": "TF1.fr"}))
.collect();
return (200, Value::Array(list).to_string());
}
(200, "{}".into())
}))
.await;
let api = api(&base, true);
let ids: Vec<String> = (1..=5).map(|i| i.to_string()).collect();
let responses =
futures::future::join_all(ids.iter().map(|id| api.handle(Some(playlist(&base)), id)))
.await;
assert!(responses.iter().all(|r| r.status().as_u16() == 200));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cache_hit_au_second_appel() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (
200,
dump(
"France2.fr",
"FRANCE 2",
now,
now + chrono::Duration::minutes(25),
),
);
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 7, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let api = api(&base, false);
let first = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(first.headers()["x-cache"], "MISS");
let second = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(second.headers()["x-cache"], "HIT");
assert!(api.handle(None, "7").await.status().as_u16() == 403);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! `GET /api/logo?src=<stream_icon>&name=<nom de chaîne>`
//!
//! Logo d'une chaîne, avec repli. L'URL du panneau est tentée d'abord, via
//! le proxy Xtream (mêmes protections anti-SSRF, même mémoire des hôtes
//! morts). En cas d'échec, le logo est cherché par nom dans le dépôt public
//! `tv-logos`.
//!
//! POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
//! perd ses logos d'un coup, alors que les autres applications IPTV les
//! affichent encore grâce à leurs propres sources de repli.
use crate::api::proxy::missing_image_response;
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::http::{HeaderMap, Method};
/// Un logo trouvé change rarement : un jour de cache navigateur.
const FOUND: &str = "public, max-age=86400";
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let params = req.query_params();
let src = params.get("src").cloned().unwrap_or_default();
let name = params.get("name").cloned().unwrap_or_default();
if src.starts_with("http://") || src.starts_with("https://") {
let (path, query) = match src.split_once('?') {
Some((p, q)) => (p, q),
None => (src.as_str(), ""),
};
let path = path.split('#').next().unwrap_or("");
let query = query.split('#').next().unwrap_or("");
// Requête interne vers le proxy, avec la seule session du client.
let mut headers = HeaderMap::new();
for header in ["cookie", "authorization"] {
if let Some(value) = req.headers.get(header) {
headers.insert(header, value.clone());
}
}
let proxied = Req {
method: Method::GET,
path: format!("api/xtream/{path}"),
query: query.to_string(),
headers,
body: Default::default(),
remote: req.remote,
user: None,
user_id: None,
};
let upstream = state.proxy.handle(state, &proxied).await;
let content_type = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if upstream.status().as_u16() == 200 && content_type.starts_with("image/") {
return http::with_headers(
http::response(200, upstream.into_body()),
&[("content-type", &content_type), ("cache-control", FOUND)],
);
}
// Réponse abandonnée : la connexion amont est refermée.
drop(upstream);
}
if !name.is_empty()
&& let Some(bytes) = state.logo_catalog.logo_for(&name).await
{
return http::with_headers(
http::response(200, bytes),
&[("content-type", "image/png"), ("cache-control", FOUND)],
);
}
missing_image_response()
}
+11
View File
@@ -0,0 +1,11 @@
pub mod account;
pub mod admin;
pub mod epg;
pub mod logo;
pub mod player_log;
pub mod proxy;
pub mod recording_playlist;
pub mod recordings;
pub mod static_files;
pub mod streaming;
pub mod xtream_api;
+135
View File
@@ -0,0 +1,135 @@
//! Journal des incidents de lecture remontés par le lecteur web.
//!
//! POURQUOI : les freezes se produisent chez l'utilisateur, dans son
//! navigateur ; le serveur n'en voit rien. Le lecteur envoie ici chaque
//! coupure, erreur, recréation et saut, et le serveur les écrit dans
//! `docker logs` (`[Player] …`).
//!
//! Entrée non fiable : seuls des champs connus, courts et typés passent.
use crate::http::{self, Req, Resp};
use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
const EVENTS: [&str; 11] = [
"start",
"first_frame",
"stall",
"seek",
"mpegts_error",
"recreate",
"fallback_hls",
"hls_error",
"profile",
"error_shown",
"heartbeat",
];
const NUMERIC_FIELDS: [&str; 11] = [
"t", "ms", "buffer", "pos", "from", "to", "stalls", "stallMs", "seeks", "errors", "rate",
];
const TEXT_FIELDS: [&str; 5] = ["session", "ch", "mode", "profile", "detail"];
static URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://\S+").unwrap());
static UNSAFE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9_.\-:/<>]").unwrap());
/// Ligne de journal pour l'événement [raw], ou `None` s'il est invalide.
pub fn format_player_event(raw: &Value) -> Option<String> {
let map = raw.as_object()?;
let event = map.get("event")?.as_str()?;
if !EVENTS.contains(&event) {
return None;
}
let mut parts = vec![event.to_string()];
for key in TEXT_FIELDS {
if let Some(value) = map.get(key).and_then(Value::as_str) {
if value.is_empty() {
continue;
}
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
// journaux, jamais d'identifiants recopiés depuis une URL.
let safe = URL.replace_all(value, "<url>");
let safe = UNSAFE.replace_all(&safe, "_");
let safe: String = safe.chars().take(60).collect();
parts.push(format!("{key}={safe}"));
}
}
for key in NUMERIC_FIELDS {
let Some(Value::Number(n)) = map.get(key) else {
continue;
};
if let Some(i) = n.as_i64() {
parts.push(format!("{key}={i}"));
} else if let Some(f) = n.as_f64().filter(|f| f.is_finite()) {
parts.push(format!("{key}={f:.1}"));
}
}
Some(format!("[Player] {}", parts.join(" ")))
}
pub fn handle(req: &Req) -> Resp {
let body = req.body_str();
if body.encode_utf16().count() > 4096 {
return http::empty(413);
}
let Ok(decoded) = serde_json::from_str::<Value>(&body) else {
return http::text(400, "Bad Request");
};
let events: Vec<&Value> = match &decoded {
Value::Array(list) => list.iter().take(20).collect(),
other => vec![other],
};
for event in events {
if let Some(line) = format_player_event(event) {
println!("{line}");
}
}
http::empty(204)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ecrit_une_coupure_avec_sa_duree_et_le_buffer() {
assert_eq!(
format_player_event(&json!({
"event": "stall", "session": "ab12", "ch": "554034", "ms": 5230, "buffer": 0.04
}))
.unwrap(),
"[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0"
);
}
#[test]
fn refuse_un_evenement_inconnu() {
assert!(format_player_event(&json!({"event": "rm -rf"})).is_none());
assert!(format_player_event(&json!("stall")).is_none());
assert!(format_player_event(&Value::Null).is_none());
}
#[test]
fn pas_d_injection_ni_d_url() {
let line = format_player_event(&json!({
"event": "mpegts_error",
"detail": "NetworkError http://panel/live/user/pass/1.ts\n[Player] faux"
}))
.unwrap();
assert!(!line.contains('\n'));
assert!(!line.contains("user/pass"));
assert!(line.contains("<url>"));
}
#[test]
fn ignore_les_champs_inattendus() {
let line =
format_player_event(&json!({"event": "seek", "from": 10, "cookie": "secret"})).unwrap();
assert!(line.contains("from=10"));
assert!(!line.contains("to="));
assert!(!line.contains("secret"));
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Proxy Xtream `/api/xtream/<url>` (images, API du panneau) avec
//! protections anti-SSRF, et mémoire des hôtes d'images hors service.
use crate::http::{self, Req, Resp, extract_token};
use crate::models::PlaylistConfig;
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Réponse pour une image indisponible.
///
/// Statut 410 et non 404 : la cascade du serveur retombe sur l'étage suivant
/// pour tout 404 et renverrait finalement celui des fichiers statiques, sans
/// notre `cache-control`. Un 410 s'arrête ici.
///
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
/// est essentiel : sans lui le navigateur redemande le logo à chaque
/// affichage de la grille.
pub fn missing_image_response() -> Resp {
http::with_headers(
http::empty(410),
&[
("cache-control", "public, max-age=600"),
("access-control-allow-origin", "*"),
],
)
}
/// Mémoire courte des hôtes d'images injoignables.
///
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
/// sur le panneau. Quand cette machine tombe, elle répond 5xx en quelques
/// millisecondes, le navigateur réessaie à chaque rendu de la grille et le
/// proxy relaie des centaines de requêtes mortes par seconde. Au bout de
/// [threshold] échecs consécutifs, l'hôte est tenu pour mort pendant [ttl]
/// et les requêtes suivantes sont servies localement, sans appel sortant.
pub struct AssetFailureCache {
threshold: u32,
ttl: Duration,
failures: Mutex<HashMap<String, u32>>,
down_until: Mutex<HashMap<String, Instant>>,
}
impl AssetFailureCache {
pub fn new(threshold: u32, ttl: Duration) -> Self {
Self {
threshold,
ttl,
failures: Mutex::default(),
down_until: Mutex::default(),
}
}
pub fn is_down(&self, host: &str) -> bool {
self.is_down_at(host, Instant::now())
}
fn is_down_at(&self, host: &str, now: Instant) -> bool {
let key = host.to_lowercase();
let mut down = self.down_until.lock().unwrap();
let Some(until) = down.get(&key).copied() else {
return false;
};
if now < until {
return true;
}
// Coupure expirée : ardoise vierge pour laisser une vraie chance.
down.remove(&key);
self.failures.lock().unwrap().remove(&key);
false
}
pub fn record_failure(&self, host: &str) {
self.record_failure_at(host, Instant::now());
}
fn record_failure_at(&self, host: &str, now: Instant) {
let key = host.to_lowercase();
let count = {
let mut failures = self.failures.lock().unwrap();
let count = failures.entry(key.clone()).or_insert(0);
*count += 1;
*count
};
if count >= self.threshold {
self.down_until.lock().unwrap().insert(key, now + self.ttl);
}
}
pub fn record_success(&self, host: &str) {
let key = host.to_lowercase();
self.failures.lock().unwrap().remove(&key);
self.down_until.lock().unwrap().remove(&key);
}
}
/// L'URL désigne-t-elle une image ou un logo ? Ces URL échappent à
/// l'allowlist de domaine (les revendeurs hébergent leurs picons ailleurs
/// que le panneau) et méritent un repli visuel plutôt qu'une erreur.
fn is_static_asset_url(url: &reqwest::Url) -> bool {
let path = url.path();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/picons/")
|| path.contains("/logos/")
}
/// Hôte à ne jamais atteindre par le proxy (loopback, LAN privé,
/// link-local/métadonnées cloud) — protection SSRF des URL d'images qui
/// échappent à l'allowlist de domaine.
pub fn is_forbidden_proxy_host(host: &str) -> bool {
let lower = host.to_lowercase();
let lower = lower.trim_start_matches('[').trim_end_matches(']');
if lower == "localhost" || lower == "::1" {
return true;
}
let Ok(ip) = lower.parse::<IpAddr>() else {
return false; // nom d'hôte : validé par l'allowlist de domaine
};
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o[0] == 0
}
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn host_of(url: &reqwest::Url) -> String {
url.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']')
.to_string()
}
const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
"content-type",
"content-range",
"accept-ranges",
"cache-control",
"server",
"date",
];
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
pub struct ProxyHandler {
asset_failures: AssetFailureCache,
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
/// une requête SQL par segment.
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
}
enum ProxyError {
Response(Resp),
Failure(String),
}
impl ProxyHandler {
pub fn new() -> Self {
Self {
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
playlist_cache: Mutex::new(None),
}
}
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
&& Instant::now() < *expiry
{
return Some(playlist.clone());
}
let playlist = state.get_playlist(req)?;
*self.playlist_cache.lock().unwrap() =
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
Some(playlist)
}
pub async fn handle(&self, state: &AppState, req: &Req) -> Resp {
// Le test de chemin passe AVANT tout : les autres requêtes doivent
// retomber sur les étages suivants de la cascade.
let Some(api_path) = req.path.strip_prefix("api/xtream/") else {
return http::empty(404);
};
// Authentification par session (en-tête ou cookie HttpOnly posé au
// login : `img src` et hls.js n'envoient que le cookie). Un proxy
// ouvert offrait un rebond SSRF non authentifié.
let authorized = extract_token(req)
.is_some_and(|token| state.db.find_session_by_token(&token).is_some());
if !authorized {
return http::text(401, "Unauthorized");
}
let mut api_path = api_path.to_string();
if api_path.starts_with("http%3A") || api_path.starts_with("https%3A") {
api_path = percent_encoding::percent_decode_str(&api_path)
.decode_utf8_lossy()
.into_owned();
}
if !api_path.starts_with("http://") && !api_path.starts_with("https://") {
return http::text(
400,
"Invalid API URL. Expected format: /api/xtream/http://...",
);
}
let mut full_url = api_path;
if !req.query.is_empty() {
full_url.push(if full_url.contains('?') { '&' } else { '?' });
full_url.push_str(&req.query);
}
let target = reqwest::Url::parse(&full_url).ok();
match self.forward(state, req, target.clone()).await {
Ok(resp) => resp,
Err(ProxyError::Response(resp)) => resp,
Err(ProxyError::Failure(error)) => {
// Une erreur reqwest recopie l'URL amont, identifiants inclus :
// jamais de détail vers le client, log masqué.
println!(
"[ProxyHandler] error on {}: {}",
redact_url(&req.path),
redact_url(&error)
);
if let Some(target) = target.filter(is_static_asset_url) {
self.asset_failures.record_failure(&host_of(&target));
return missing_image_response();
}
http::json(500, &json!({"error": "Proxy error"}))
}
}
}
async fn forward(
&self,
state: &AppState,
req: &Req,
target: Option<reqwest::Url>,
) -> Result<Resp, ProxyError> {
let target =
target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?;
if target.scheme() != "http" && target.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported URL scheme",
)));
}
let target_host = host_of(&target);
if is_forbidden_proxy_host(&target_host) {
println!("[Proxy] Blocked SSRF attempt to private host: {target_host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
// Images : tout hôte autorisé (CDN de picons des revendeurs).
let is_static_asset = is_static_asset_url(&target);
// Hôte d'images déjà constaté mort : réponse immédiate, sans appel
// sortant ni log — c'est précisément le flot qu'on veut éteindre.
if is_static_asset && self.asset_failures.is_down(&target_host) {
return Ok(missing_image_response());
}
let mut allowed_host = None;
if !is_static_asset {
// Appels d'API : allowlist de domaine.
let Some(playlist) = self.cached_playlist(state, req) else {
return Err(ProxyError::Response(http::text(
403,
"No active playlist configuration found to validate request",
)));
};
let allowed = reqwest::Url::parse(&playlist.dns)
.map(|u| host_of(&u).to_lowercase())
.unwrap_or_default();
if target_host.to_lowercase() != allowed {
println!(
"[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})",
target_host.to_lowercase()
);
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
allowed_host = Some(allowed);
}
let mut headers = reqwest::header::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
headers.insert(n, v);
}
};
set("user-agent", "VLC/3.0.18 LibVLC/3.0.18");
set("accept", "*/*");
set("accept-encoding", "identity");
for name in ALLOWED_REQUEST_HEADERS {
if let Some(value) = req.header(name) {
set(name, value);
}
}
println!("[Proxy] Forwarding to: {}", redact_url(target.as_str()));
let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone());
// Redirections suivies À LA MAIN : chaque destination est revalidée
// (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à
// atteindre un hôte interne malgré l'anti-SSRF.
let mut current = target.clone();
let mut redirects = 0;
let response = loop {
let mut builder = state
.http_manual
.request(method.clone(), current.clone())
.headers(headers.clone());
if let Some(body) = &post_body {
builder = builder.body(body.clone());
}
// 90 s : laisse le front (60 s) abandonner proprement le premier.
let response = tokio::time::timeout(Duration::from_secs(90), builder.send())
.await
.map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))?
.map_err(|e| ProxyError::Failure(format!("{e:?}")))?;
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location.filter(|_| (300..400).contains(&status)) else {
break response;
};
redirects += 1;
if redirects > 3 {
return Err(ProxyError::Response(http::text(403, "Too many redirects")));
}
current = current
.join(&location)
.map_err(|e| ProxyError::Failure(e.to_string()))?;
if current.scheme() != "http" && current.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported redirect scheme",
)));
}
let host = host_of(&current);
if is_forbidden_proxy_host(&host) {
println!("[Proxy] Blocked SSRF redirect to private host: {host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
if let Some(allowed) = &allowed_host
&& host.to_lowercase() != *allowed
{
println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})");
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
};
// Une image en erreur n'a rien à transmettre au navigateur : relayer
// le 503 déclenchait un nouveau cycle de requêtes à chaque rendu.
// L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus).
let status = response.status().as_u16();
if is_static_asset {
if status >= 400 {
self.asset_failures.record_failure(&target_host);
return Ok(missing_image_response());
}
self.asset_failures.record_success(&target_host);
}
let upstream_headers = response.headers().clone();
let mut resp = http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
);
http::set_header(&mut resp, "access-control-allow-origin", "*");
for name in ALLOWED_RESPONSE_HEADERS {
let values: Vec<&str> = upstream_headers
.get_all(name)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
if !values.is_empty() {
http::set_header(&mut resp, name, &values.join(", "));
}
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloque_le_loopback() {
assert!(is_forbidden_proxy_host("127.0.0.1"));
assert!(is_forbidden_proxy_host("localhost"));
assert!(is_forbidden_proxy_host("::1"));
assert!(is_forbidden_proxy_host("[::1]"));
}
#[test]
fn bloque_les_plages_privees() {
assert!(is_forbidden_proxy_host("10.0.0.5"));
assert!(is_forbidden_proxy_host("172.16.0.1"));
assert!(is_forbidden_proxy_host("172.31.255.255"));
assert!(is_forbidden_proxy_host("192.168.1.10"));
assert!(is_forbidden_proxy_host("169.254.169.254"));
assert!(is_forbidden_proxy_host("0.0.0.0"));
}
#[test]
fn autorise_les_ip_publiques_et_les_noms() {
assert!(!is_forbidden_proxy_host("8.8.8.8"));
assert!(!is_forbidden_proxy_host("172.32.0.1"));
assert!(!is_forbidden_proxy_host("cdn.example.com"));
}
#[test]
fn cache_des_hotes_morts() {
let cache = AssetFailureCache::new(3, Duration::from_secs(300));
let t0 = Instant::now();
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
cache.record_success("cdn.example");
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("CDN.example", t0);
cache.record_failure_at("cdn.example", t0);
assert!(cache.is_down_at("cdn.example", t0));
// Coupure expirée : ardoise vierge.
assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301)));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Adressage des sessions de lecture d'un enregistrement.
//!
//! Un enregistrement est transcodé à la volée, séquentiellement. Reprendre à
//! 45 min sans le dire à FFmpeg imposerait d'attendre que l'encodeur y
//! arrive : le lecteur demande donc une playlist qui *commence* à cette
//! position (`?start=2700`), servie par une session FFmpeg dédiée.
use regex::Regex;
use std::sync::LazyLock;
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
pub fn parse_recording_start(raw: Option<&str>) -> u64 {
let Some(value) = raw.and_then(|r| r.trim().parse::<f64>().ok()) else {
return 0;
};
if !value.is_finite() || value <= 0.0 {
return 0;
}
value.floor() as u64
}
/// Segment de chemin qui rattache un `.ts` à SA session : la query string ne
/// survit pas aux URL relatives de la playlist, et les playlists démarrées à
/// 0 s et à 45 min demanderaient sinon toutes deux `segment_000.ts`.
pub fn recording_offset_key(start: u64) -> String {
format!("t{start}")
}
/// Inverse de [recording_offset_key] ; `None` si la clé est mal formée.
pub fn parse_recording_offset_key(key: &str) -> Option<u64> {
let digits = key.strip_prefix('t')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit() || b == b'+') {
return None;
}
digits.parse::<u64>().ok()
}
static SEGMENT_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(segment_\d+\.ts)\s*$").unwrap());
/// Préfixe les lignes de segment de [playlist] par [offset_key] ; balises
/// `#EXT-X-…` et lignes vides passent telles quelles.
pub fn rewrite_recording_playlist(playlist: &str, offset_key: &str) -> String {
SEGMENT_LINE
.replace_all(playlist, |caps: &regex::Captures| {
format!("{offset_key}/{}", &caps[1])
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lit_la_position() {
assert_eq!(parse_recording_start(Some("2700")), 2700);
assert_eq!(parse_recording_start(Some("2700.9")), 2700);
for bad in [
None,
Some(""),
Some("abc"),
Some("-30"),
Some("NaN"),
Some("Infinity"),
] {
assert_eq!(parse_recording_start(bad), 0);
}
}
#[test]
fn cle_d_offset_aller_retour() {
for start in [0, 1, 2700, 86400] {
assert_eq!(
parse_recording_offset_key(&recording_offset_key(start)),
Some(start)
);
}
for bad in ["t", "segment_000.ts", "..", "t-30", "tabc"] {
assert_eq!(parse_recording_offset_key(bad), None, "{bad}");
}
}
const PLAYLIST: &str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXTINF:4.000000,\nsegment_000.ts\n#EXTINF:4.000000,\nsegment_001.ts\n";
#[test]
fn rattache_chaque_segment_a_sa_session() {
let out = rewrite_recording_playlist(PLAYLIST, "t2700");
assert!(out.contains("t2700/segment_000.ts"));
assert!(out.contains("t2700/segment_001.ts"));
assert!(!out.contains("\nsegment_"));
}
#[test]
fn laisse_les_balises() {
let out = rewrite_recording_playlist(PLAYLIST, "t0");
assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:3\n"));
assert!(out.contains("#EXT-X-PLAYLIST-TYPE:EVENT"));
assert_eq!(out.matches("#EXTINF:4.000000,").count(), 2);
}
#[test]
fn idempotent() {
let once = rewrite_recording_playlist(PLAYLIST, "t60");
assert_eq!(rewrite_recording_playlist(&once, "t60"), once);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! `/api/recordings` : liste, programmation, arrêt, suppression, logs.
//! `/api/season-passes` : enregistrements répétés par titre d'émission.
use crate::dart_time::DartDateTime;
use crate::http::{self, Req, Resp, json, segments};
use crate::models::{Recording, User};
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
use crate::state::SharedState;
use crate::util::{media_probe, safe_path};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::LazyLock;
/// L'utilisateur peut-il agir sur cet enregistrement ?
fn can_access(user: Option<&User>, recording: &Recording) -> bool {
user.is_some_and(|u| u.is_admin || recording.user_id == u.id)
}
pub async fn recordings(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list(state, req).await,
("POST", []) => create(state, req),
("DELETE", [id]) => delete(state, req, id),
("POST", ["stop", id]) => stop(state, req, id),
("GET" | "HEAD", ["logs", id]) => logs(state, req, id).await,
_ => http::not_found(),
}
}
async fn logs(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
let Some(file_path) = &recording.file_path else {
return json(
404,
&json!({"error": "Aucun fichier ni log associé pour le moment."}),
);
};
// Enregistrements en .mkv, log .log à côté.
let log_path = Path::new(file_path).with_extension("log");
// Anti traversée : le log doit rester dans le dossier des enregistrements.
let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), &log_path.to_string_lossy())
else {
return json(403, &json!({"error": "Chemin de log invalide"}));
};
match tokio::fs::read(&safe).await {
Ok(bytes) => json(200, &json!({"logs": String::from_utf8_lossy(&bytes)})),
Err(_) => json(404, &json!({"error": "Le fichier de log est introuvable."})),
}
}
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
/// enrichis du suivi : taille, progression, relances, durée réelle.
async fn list(state: &SharedState, req: &Req) -> Resp {
let recordings = match &req.user {
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
_ => state.db.get_all_recordings(),
};
let now = Utc::now();
let mut enriched = Vec::with_capacity(recordings.len());
for r in &recordings {
let mut map = enrich(&state.scheduler, r, now);
// Durée réelle du média : `end_time - start_time` n'est que la durée
// programmée, fausse pour un enregistrement arrêté ou tronqué.
if let Some(duration) = probe_duration(r).await {
map.insert("duration_seconds".into(), json!(duration));
}
enriched.push(Value::Object(map));
}
json(200, &Value::Array(enriched))
}
/// Une capture en cours grossit en permanence : le cache de ffprobe serait
/// invalidé à chaque appel. Seuls les enregistrements terminés sont mesurés.
async fn probe_duration(r: &Recording) -> Option<f64> {
let path = r.file_path.as_deref()?;
if r.status != "completed" {
return None;
}
let safe = safe_path::resolve_within(&recordings_dir_path(), path)?;
media_probe::duration(&safe).await
}
fn enrich(scheduler: &RecordingScheduler, r: &Recording, now: DateTime<Utc>) -> Map<String, Value> {
let mut map = r.to_map();
if r.status == "recording" {
let start = r.start_utc();
let total = (r.end_utc() - start).num_seconds();
if total > 0 {
let elapsed = (now - start).num_seconds();
let pct = (elapsed as f64 * 100.0 / total as f64)
.clamp(0.0, 100.0)
.round() as i64;
map.insert("progress_pct".into(), json!(pct));
}
map.insert("is_active".into(), json!(scheduler.is_capturing(&r.id)));
if let Some(retries) = scheduler.retry_count_of(&r.id) {
map.insert("retry_count".into(), json!(retries));
}
}
if let Some(path) = &r.file_path
&& let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
{
map.insert("file_size_bytes".into(), json!(meta.len()));
}
map
}
static ZONE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+-]\d{2}:?\d{2}$").unwrap());
/// Date ISO-8601 AVEC fuseau, normalisée en UTC. Une date naïve est
/// ambiguë : l'interpréter dans le fuseau du serveur décalait
/// l'enregistrement de plusieurs heures selon le TZ du conteneur.
fn parse_zoned_date(raw: Option<&Value>) -> Option<DateTime<Utc>> {
let s = match raw? {
Value::String(s) => s.clone(),
Value::Null => return None,
other => other.to_string(),
};
if s.is_empty() || !(s.ends_with('Z') || ZONE_SUFFIX.is_match(&s)) {
return None;
}
DartDateTime::parse(&s).map(|d| d.to_utc())
}
fn to_text(v: Option<&Value>) -> Option<String> {
match v? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
fn create(state: &SharedState, req: &Req) -> Resp {
let Some(user_id) = req
.user
.as_ref()
.map(|u| u.id.clone())
.or(req.user_id.clone())
else {
return json(401, &json!({"error": "Authentification requise"}));
};
let Ok(Value::Object(data)) = serde_json::from_slice::<Value>(&req.body) else {
return json(400, &json!({"error": "Corps JSON invalide"}));
};
let channel_id = to_text(data.get("channel_id")).unwrap_or_default();
let stream_url = to_text(data.get("stream_url")).unwrap_or_default();
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let (Some(start), Some(end)) = (
parse_zoned_date(data.get("start_time")),
parse_zoned_date(data.get("end_time")),
) else {
return json(
400,
&json!({"error": "start_time et end_time doivent être des dates ISO-8601 avec fuseau (ex: 2026-08-27T21:00:00Z)"}),
);
};
if end <= start {
return json(
400,
&json!({"error": "end_time doit être après start_time"}),
);
}
let max_hours: i64 = std::env::var("MAX_RECORDING_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12);
if end - start > chrono::Duration::hours(max_hours) {
return json(
400,
&json!({"error": format!("Durée maximale dépassée ({max_hours} h)")}),
);
}
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
match state
.db
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
{
Ok(recording) => json(200, &Value::Object(recording.to_map())),
Err(e) => {
println!("[RecordingsApi] Erreur à la création: {e}");
json(500, &json!({"error": "Erreur lors de la programmation"}))
}
}
}
/// Annule ou supprime un enregistrement : FFmpeg arrêté s'il tourne,
/// fichiers (.mkv, .log, parties) supprimés avec la ligne.
fn delete(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
// Tuer FFmpeg AVANT de supprimer de la base.
state.scheduler.stop_recording(id);
if let Some(path) = &recording.file_path {
// Confiné au dossier des enregistrements.
if let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), path) {
RecordingScheduler::delete_recording_files(&safe);
}
}
state.db.delete_recording(id);
json(
200,
&json!({"message": "Enregistrement supprimé avec succès"}),
)
}
fn stop(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
if state.scheduler.stop_recording(id) {
return json(200, &json!({"message": "Enregistrement arrêté"}));
}
match recording.status.as_str() {
// Rien capturé : annulé, pas « terminé » (la lecture échouerait).
"scheduled" => {
state
.db
.update_recording_status(id, "cancelled", None, None, false);
json(200, &json!({"message": "Enregistrement annulé"}))
}
// « recording » sans processus (orphelin) : clôturer.
"recording" => {
state
.db
.update_recording_status(id, "completed", None, None, false);
json(
200,
&json!({"message": "Enregistrement marqué comme terminé"}),
)
}
// Déjà clôturé : ne pas écraser le statut final.
_ => json(200, &json!({"message": "Enregistrement déjà clôturé"})),
}
}
// ==================== /api/season-passes ====================
pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
let passes = if user.is_admin {
state.db.get_all_season_passes()
} else {
state.db.get_season_passes_for_user(&user.id)
};
json(
200,
&Value::Array(passes.into_iter().map(Value::Object).collect()),
)
}
("POST", []) => create_pass(state, req),
("DELETE", [id]) => {
let Some(pass) = state.db.get_season_pass_by_id(id) else {
return json(404, &json!({"error": "Season Pass non trouvé"}));
};
// Seul le propriétaire ou un admin supprime.
let owner = pass.get("user_id").and_then(Value::as_str);
let allowed = req
.user
.as_ref()
.is_some_and(|u| u.is_admin || owner == Some(u.id.as_str()));
if !allowed {
return json(403, &json!({"error": "Accès refusé"}));
}
state.db.delete_season_pass(id);
json(200, &json!({"message": "Season Pass supprimé"}))
}
_ => http::not_found(),
}
}
fn create_pass(state: &SharedState, req: &Req) -> Resp {
let data = match serde_json::from_slice::<Value>(&req.body) {
Ok(Value::Object(map)) => map,
Ok(_) => return json(500, &json!({"error": "Erreur: invalid body"})),
Err(e) => {
return json(
500,
&json!({"error": format!("Erreur: FormatException: {e}")}),
);
}
};
let field = |k: &str| {
data.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let (show_title, channel_id, stream_url) = (
field("show_title"),
field("channel_id"),
field("stream_url"),
);
if show_title.is_empty() {
return json(400, &json!({"error": "show_title est requis"}));
}
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot.
let match_mode = if data.get("match_mode").and_then(Value::as_str) == Some("contains") {
"contains"
} else {
"exact"
};
let duplicate = state
.db
.get_season_passes_for_user(&user.id)
.iter()
.any(|p| {
p.get("show_title")
.and_then(Value::as_str)
.is_some_and(|t| t.to_lowercase() == show_title.to_lowercase())
&& p.get("channel_id").and_then(Value::as_str) == Some(channel_id.as_str())
});
if duplicate {
return json(
409,
&json!({"error": "Un Season Pass identique existe déjà"}),
);
}
match state
.db
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
{
Ok(pass) => {
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
json(201, &Value::Object(pass))
}
Err(e) => json(500, &json!({"error": format!("Erreur: {e}")})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exige_un_fuseau() {
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).is_some());
assert_eq!(
parse_zoned_date(Some(&json!("2026-08-27T23:00:00+02:00"))).unwrap(),
parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).unwrap()
);
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00"))).is_none());
assert!(parse_zoned_date(Some(&json!(""))).is_none());
assert!(parse_zoned_date(None).is_none());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Fichiers statiques de la build web, avec politique de cache.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::body::Body;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio_util::io::ReaderStream;
const NO_STORE: [(&str, &str); 3] = [
(
"cache-control",
"no-store, no-cache, must-revalidate, max-age=0",
),
("pragma", "no-cache"),
("expires", "0"),
];
/// Sert la build web en appliquant la politique de cache :
/// - points d'entrée versionnés (`?v=<empreinte>`) servis réécrits, jamais
/// mis en cache ;
/// - `.html`, `.js`, `.json` sans cache pour que les mises à jour soient vues
/// tout de suite, sauf les libs vendorisées (hls.js/mpegts.js, ~750 Ko,
/// versions figées) ;
/// - le reste en cache un jour.
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let path = req.path.as_str();
let key = if path.is_empty() { "index.html" } else { path };
if req.is_get()
&& let Some(content) = state.versioned.get(key)
{
let content_type = if path.ends_with(".js") {
"text/javascript; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
let mut resp = http::with_headers(
http::response(200, content.clone()),
&[("content-type", content_type)],
);
for (name, value) in NO_STORE {
http::set_header(&mut resp, name, value);
}
return resp;
}
let resp = serve_file(&state.web_path, req).await;
let vendored = path.starts_with("vendor/");
if !vendored
&& (path.is_empty()
|| path.ends_with(".html")
|| path.ends_with(".js")
|| path.ends_with(".json"))
{
return http::with_headers(resp, &NO_STORE);
}
http::with_headers(resp, &[("cache-control", "public, max-age=86400")])
}
/// Chemin disque de la requête, ou `None` s'il sort de la racine.
fn resolve(root: &str, path: &str) -> Option<PathBuf> {
let mut resolved = PathBuf::from(root);
for segment in path.split('/').filter(|s| !s.is_empty()) {
let decoded = percent_encoding::percent_decode_str(segment)
.decode_utf8()
.ok()?;
if decoded == "." || decoded == ".." || decoded.contains(['/', '\\', ':', '\0']) {
return None;
}
resolved.push(decoded.as_ref());
}
Some(resolved)
}
fn http_date(time: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = time.into();
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
async fn serve_file(root: &str, req: &Req) -> Resp {
if !req.is_get() {
return http::text(405, "Method Not Allowed");
}
let Some(mut file_path) = resolve(root, &req.path) else {
return http::not_found();
};
if file_path.is_dir() {
// Répertoire sans `/` final : redirection, comme shelf_static.
if !req.path.is_empty() && !req.path.ends_with('/') {
let index = file_path.join("index.html");
if index.is_file() {
let mut resp = http::empty(301);
http::set_header(&mut resp, "location", &format!("/{}/", req.path));
return resp;
}
return http::not_found();
}
file_path = file_path.join("index.html");
}
let Ok(meta) = tokio::fs::metadata(&file_path).await else {
return http::not_found();
};
if !meta.is_file() {
return http::not_found();
}
let modified = meta.modified().ok();
if let (Some(modified), Some(since)) = (modified, req.header("if-modified-since"))
&& let Ok(since) = chrono::DateTime::parse_from_rfc2822(since)
{
let modified: chrono::DateTime<chrono::Utc> = modified.into();
if modified.timestamp() <= since.timestamp() {
return http::empty(304);
}
}
let content_type = content_type_of(&file_path);
let body = if req.method == axum::http::Method::HEAD {
Body::empty()
} else {
match tokio::fs::File::open(&file_path).await {
Ok(file) => Body::from_stream(ReaderStream::new(file)),
Err(_) => return http::not_found(),
}
};
let mut resp = http::response(200, body);
http::set_header(&mut resp, "content-length", &meta.len().to_string());
if let Some(ct) = content_type {
http::set_header(&mut resp, "content-type", &ct);
}
if let Some(modified) = modified {
http::set_header(&mut resp, "last-modified", &http_date(modified));
}
resp
}
fn content_type_of(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
Some(match ext.as_str() {
"js" | "mjs" => "text/javascript".to_string(),
"html" | "htm" => "text/html".to_string(),
"wasm" => "application/wasm".to_string(),
_ => mime_guess::from_ext(&ext).first_raw()?.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuse_la_traversee() {
assert!(resolve("/app/web", "../etc/passwd").is_none());
assert!(resolve("/app/web", "a/%2e%2e/b").is_none());
assert!(resolve("/app/web", "a/..%2Fb").is_none());
assert_eq!(
resolve("/app/web", "vendor/hls.min.js").unwrap(),
PathBuf::from("/app/web").join("vendor").join("hls.min.js")
);
}
}
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
//! Passerelle authentifiée vers `player_api.php` du panneau Xtream.
//!
//! Le front ne voit jamais les identifiants Xtream : il appelle
//! `GET /api/xtream-api?action=...` avec son jeton de session et le serveur
//! injecte username/password de la playlist de l'utilisateur.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::time::Duration;
/// Paramètres que le client peut transmettre à Xtream.
const ALLOWED_PARAMS: [&str; 7] = [
"action",
"category_id",
"stream_id",
"series_id",
"vod_id",
"limit",
"type",
];
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let Some(playlist) = state.get_playlist(req) else {
return http::json(403, &json!({"error": "No playlist configured"}));
};
let Ok(mut target) = reqwest::Url::parse(&format!("{}/player_api.php", playlist.dns)) else {
return http::json(500, &json!({"error": "Upstream Xtream API error"}));
};
{
let mut pairs = target.query_pairs_mut();
pairs.clear();
pairs.append_pair("username", &playlist.username);
pairs.append_pair("password", &playlist.password);
// Ordre et dédoublonnage de la query cliente, comme `queryParameters`.
let mut seen = Vec::new();
for (key, _) in url::form_urlencoded::parse(req.query.as_bytes()) {
if ALLOWED_PARAMS.contains(&key.as_ref()) && !seen.contains(&key) {
seen.push(key);
}
}
let params = req.query_params();
for key in seen {
if let Some(value) = params.get(key.as_ref()) {
pairs.append_pair(&key, value);
}
}
}
let send = state
.http
.get(target.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.header("Accept", "*/*")
.send();
let response = match tokio::time::timeout(Duration::from_secs(90), send).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return upstream_error(&target, &format!("{e:?}")),
Err(_) => return upstream_error(&target, "TimeoutException after 0:01:30.000000"),
};
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let status = response.status().as_u16();
http::with_headers(
http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
),
&[
("content-type", &content_type),
("cache-control", "no-store"),
],
)
}
fn upstream_error(target: &reqwest::Url, error: &str) -> Resp {
println!(
"[XtreamApi] Error forwarding to {}: {}",
redact_url(target.as_str()),
redact_url(error)
);
http::json(500, &json!({"error": "Upstream Xtream API error"}))
}
+252
View File
@@ -0,0 +1,252 @@
//! Dates au format de l'ancien serveur Dart.
//!
//! POURQUOI : la base SQLite de production a été écrite par le serveur Dart.
//! Ses colonnes de dates contiennent la sortie de `DateTime.toIso8601String()`
//! (heure locale sans suffixe pour les sessions et les horodatages de
//! création, UTC avec `Z` pour les fenêtres d'enregistrement), et certaines
//! requêtes les comparent comme des chaînes (`expires_at < ?`,
//! `start_time BETWEEN ? AND ?`). Reproduire exactement ce format garde la
//! base existante lisible et les comparaisons justes, sans migration.
use chrono::{
DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
};
use regex::Regex;
use std::sync::LazyLock;
/// Équivalent d'un `DateTime` Dart : un instant « mural » et un drapeau UTC.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DartDateTime {
pub naive: NaiveDateTime,
pub utc: bool,
}
/// Grammaire de `DateTime.parse` (SDK Dart).
static PARSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"^([+-]?\d{4,6})-?(\d\d)-?(\d\d)",
r"(?:[ T](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d+))?)?)?",
r"( ?[zZ]| ?([-+])(\d\d)(?::?(\d\d))?)?)?$",
))
.unwrap()
});
fn truncate_micros(naive: NaiveDateTime) -> NaiveDateTime {
let micros = naive.and_utc().timestamp_subsec_micros();
naive.with_nanosecond(micros * 1000).unwrap_or(naive)
}
impl DartDateTime {
/// `DateTime.now()` : heure locale, précision microseconde.
pub fn now_local() -> Self {
Self {
naive: truncate_micros(Local::now().naive_local()),
utc: false,
}
}
pub fn from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.naive_utc()),
utc: true,
}
}
/// Instant local (`DateTime.toLocal()`).
pub fn local_from_utc(dt: DateTime<Utc>) -> Self {
Self {
naive: truncate_micros(dt.with_timezone(&Local).naive_local()),
utc: false,
}
}
/// `DateTime.tryParse`.
pub fn parse(raw: &str) -> Option<Self> {
let caps = PARSE_RE.captures(raw)?;
let num = |i: usize| -> Option<i64> { caps.get(i).map(|m| m.as_str().parse().ok())? };
let year = num(1)? as i32;
let month = num(2)? as u32;
let day = num(3)? as u32;
let hour = num(4).unwrap_or(0) as u32;
let minute = num(5).unwrap_or(0) as u32;
let second = num(6).unwrap_or(0) as u32;
let micros = caps
.get(7)
.map(|m| {
let digits = m.as_str().as_bytes();
let mut value: u32 = 0;
for i in 0..6 {
value *= 10;
if i < digits.len() {
value += (digits[i] - b'0') as u32;
}
}
value
})
.unwrap_or(0);
let date = NaiveDate::from_ymd_opt(year, month, day)?;
// Dart normalise les débordements (24:00, :60) ; on les ajoute en durée.
let base = NaiveTime::from_hms_micro_opt(0, 0, 0, micros)?;
let mut naive = date.and_time(base)
+ chrono::Duration::hours(hour as i64)
+ chrono::Duration::minutes(minute as i64)
+ chrono::Duration::seconds(second as i64);
let utc = caps.get(8).is_some();
if let Some(sign) = caps.get(9) {
let hours = num(10).unwrap_or(0);
let minutes = num(11).unwrap_or(0);
let offset = chrono::Duration::minutes(hours * 60 + minutes);
naive = if sign.as_str() == "-" {
naive + offset
} else {
naive - offset
};
}
Some(Self { naive, utc })
}
/// `DateTime.toIso8601String()`.
pub fn to_iso(self) -> String {
let n = &self.naive;
let year = n.year();
let year_str = if (0..=9999).contains(&year) {
format!("{year:04}")
} else if year < 0 {
format!("-{:06}", -year)
} else {
format!("+{year:06}")
};
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{year_str}-{:02}-{:02}T{:02}:{:02}:{:02}.{ms:03}",
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
if self.utc {
out.push('Z');
}
out
}
/// Instant absolu (`DateTime.toUtc()`).
pub fn to_utc(self) -> DateTime<Utc> {
if self.utc {
return self.naive.and_utc();
}
match Local.from_local_datetime(&self.naive) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&Utc),
chrono::LocalResult::Ambiguous(dt, _) => dt.with_timezone(&Utc),
// Heure qui n'existe pas (passage à l'heure d'été) : décaler d'une
// heure, comme le fait la libc.
chrono::LocalResult::None => Local
.from_local_datetime(&(self.naive + chrono::Duration::hours(1)))
.earliest()
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| self.naive.and_utc()),
}
}
/// `DateTime.toUtc()` gardé au format Dart.
pub fn as_utc(self) -> Self {
Self::from_utc(self.to_utc())
}
pub fn add(self, d: chrono::Duration) -> Self {
if self.utc {
Self::from_utc(self.to_utc() + d)
} else {
Self::local_from_utc(self.to_utc() + d)
}
}
}
/// `DateTime.now().toIso8601String()` (heure locale).
pub fn now_local_iso() -> String {
DartDateTime::now_local().to_iso()
}
/// Instant UTC au format Dart (`...Z`).
pub fn utc_iso(dt: DateTime<Utc>) -> String {
DartDateTime::from_utc(dt).to_iso()
}
/// `'${DateTime.now()}'` : format de `DateTime.toString()` (journaux).
pub fn now_display() -> String {
display_naive(DartDateTime::now_local().naive)
}
/// `'${instant.toLocal()}'` (journaux).
pub fn display_local(dt: DateTime<Utc>) -> String {
display_naive(DartDateTime::local_from_utc(dt).naive)
}
fn display_naive(n: NaiveDateTime) -> String {
let micros_total = n.nanosecond() / 1000;
let ms = micros_total / 1000;
let us = micros_total % 1000;
let mut out = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{ms:03}",
n.year(),
n.month(),
n.day(),
n.hour(),
n.minute(),
n.second()
);
if us != 0 {
out.push_str(&format!("{us:03}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formate_comme_dart() {
let d = DartDateTime::parse("2026-08-11T18:00:00Z").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T18:00:00.123456").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.123456");
let d = DartDateTime::parse("2026-08-11T18:00:00.120").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.120");
}
#[test]
fn lit_le_format_sqlite() {
let d = DartDateTime::parse("2026-10-12 12:03:22").unwrap();
assert!(!d.utc);
assert_eq!(d.to_iso(), "2026-10-12T12:03:22.000");
}
#[test]
fn applique_les_decalages() {
let d = DartDateTime::parse("2026-08-11T20:00:00+02:00").unwrap();
assert!(d.utc);
assert_eq!(d.to_iso(), "2026-08-11T18:00:00.000Z");
let d = DartDateTime::parse("2026-08-11T20:00:00-0130").unwrap();
assert_eq!(d.to_iso(), "2026-08-11T21:30:00.000Z");
}
#[test]
fn rejette_les_valeurs_invalides() {
assert!(DartDateTime::parse("").is_none());
assert!(DartDateTime::parse("abc").is_none());
assert!(DartDateTime::parse("2026-13-01").is_none());
}
}
+913
View File
@@ -0,0 +1,913 @@
//! Base SQLite (`/app/data/xtremflow.db`).
//!
//! Schéma, migrations et formats de dates identiques à l'ancien serveur
//! Dart : la base de production est reprise telle quelle au premier
//! démarrage du serveur Rust.
use crate::dart_time::{DartDateTime, now_local_iso, utc_iso};
use crate::models::{Playlist, Recording, Session, User};
use crate::util::password;
use chrono::{DateTime, Duration, Utc};
use regex::Regex;
use rusqlite::types::ValueRef;
use rusqlite::{Connection, OptionalExtension, Row, params};
use serde_json::{Map, Value};
use std::sync::{LazyLock, Mutex};
pub struct AppDatabase {
conn: Mutex<Connection>,
}
type DbResult<T> = Result<T, rusqlite::Error>;
fn parse_date(raw: Option<String>) -> DartDateTime {
raw.as_deref()
.and_then(DartDateTime::parse)
.unwrap_or_else(DartDateTime::now_local)
}
fn user_from_row(row: &Row) -> DbResult<User> {
Ok(User {
id: row.get("id")?,
username: row.get("username")?,
is_admin: row.get::<_, Option<i64>>("is_admin")?.unwrap_or(0) == 1,
created_at: parse_date(row.get("created_at")?),
})
}
fn playlist_from_row(row: &Row) -> DbResult<Playlist> {
Ok(Playlist {
id: row.get("id")?,
user_id: row.get("user_id")?,
name: row.get("name")?,
server_url: row.get("server_url")?,
username: row.get("username")?,
password: row.get("password")?,
dns: row.get("dns")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
fn recording_from_row(row: &Row) -> DbResult<Recording> {
let text = |name: &str| -> DbResult<String> {
Ok(row.get::<_, Option<String>>(name)?.unwrap_or_default())
};
Ok(Recording {
id: text("id")?,
user_id: text("user_id")?,
channel_id: text("channel_id")?,
stream_url: text("stream_url")?,
title: text("title")?,
start_time: parse_date(row.get("start_time")?),
end_time: parse_date(row.get("end_time")?),
status: row
.get::<_, Option<String>>("status")?
.unwrap_or_else(|| "scheduled".into()),
file_path: row.get("file_path")?,
error_reason: row.get("error_reason")?,
created_at: parse_date(row.get("created_at")?),
updated_at: parse_date(row.get("updated_at")?),
})
}
/// Ligne brute → objet JSON (colonnes dans l'ordre de la table).
fn row_to_json(row: &Row) -> DbResult<Map<String, Value>> {
let mut map = Map::new();
let stmt = row.as_ref();
for i in 0..stmt.column_count() {
let name = stmt.column_name(i)?.to_string();
let value = match row.get_ref(i)? {
ValueRef::Null => Value::Null,
ValueRef::Integer(v) => Value::from(v),
ValueRef::Real(v) => Value::from(v),
ValueRef::Text(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
ValueRef::Blob(v) => Value::from(String::from_utf8_lossy(v).into_owned()),
};
map.insert(name, value);
}
Ok(map)
}
/// Mot de passe initial sans caractères ambigus (0/O, 1/l/I) : il est
/// recopié depuis les logs du conteneur.
fn generate_password(length: usize) -> String {
const CHARS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut out = String::with_capacity(length);
while out.len() < length {
// Octets aléatoires du générateur système (via UUID v4), tirage par
// rejet pour une distribution uniforme.
for byte in uuid::Uuid::new_v4().into_bytes() {
let limit = 256 - (256 % CHARS.len());
if (byte as usize) < limit && out.len() < length {
out.push(CHARS[byte as usize % CHARS.len()] as char);
}
}
}
out
}
static GPU_SETTING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)"enable_nvidia_gpu"\s*:\s*true"#).unwrap());
impl AppDatabase {
/// Ouvre (ou crée) la base et applique schéma et migrations.
pub fn open(path: &str) -> DbResult<Self> {
if let Some(dir) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(dir);
}
let conn = Connection::open(path)?;
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
// (donc des jetons valides), playlists et enregistrements orphelins.
conn.execute_batch(
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;",
)?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.run_migrations()?;
println!("Database initialized: {path}");
Ok(db)
}
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|e| e.into_inner())
}
fn create_tables(&self) -> DbResult<()> {
self.lock().execute_batch(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
dns TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tv_recordings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
title TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
file_path TEXT,
error_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS season_passes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
show_title TEXT NOT NULL,
channel_id TEXT NOT NULL,
stream_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
match_mode TEXT NOT NULL DEFAULT 'exact',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status);
CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time);
CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id);
"#,
)
}
/// Migrations de schéma pour les bases créées par une version antérieure.
///
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
/// existante : chaque colonne introduite après coup a sa migration,
/// numérotée et rejouée uniquement si nécessaire.
fn run_migrations(&self) -> DbResult<()> {
let conn = self.lock();
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)",
[],
)?;
let mut current: i64 = conn.query_row(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version",
[],
|r| r.get(0),
)?;
let migrations: [(i64, &str, &str, &str); 2] = [
// v1 : colonne error_reason absente des bases d'avant son introduction.
(1, "tv_recordings", "error_reason", "TEXT"),
// v2 : mode de correspondance des season passes. 'contains' pour les
// lignes existantes (comportement historique) ; les nouvelles
// créations passent par l'API qui choisit 'exact' par défaut.
(
2,
"season_passes",
"match_mode",
"TEXT NOT NULL DEFAULT 'contains'",
),
];
for (version, table, column, definition) in migrations {
if version <= current {
continue;
}
let exists = {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let names = stmt.query_map([], |r| r.get::<_, String>("name"))?;
let mut found = false;
for name in names {
if name? == column {
found = true;
}
}
found
};
if !exists {
conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[],
)?;
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [version])?;
current = version;
println!("[DB] Migration v{version} appliquée");
}
Ok(())
}
/// Crée le compte admin s'il n'existe aucun utilisateur.
///
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage.
pub fn seed_admin(&self) -> DbResult<()> {
let count: i64 = self
.lock()
.query_row("SELECT COUNT(*) as count FROM users", [], |r| r.get(0))?;
if count != 0 {
return Ok(());
}
let env_password = std::env::var("ADMIN_INITIAL_PASSWORD").unwrap_or_default();
let generated = env_password.is_empty();
let password = if generated {
generate_password(16)
} else {
env_password
};
let hash = password::hash(&password).expect("bcrypt");
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1)",
params![uuid::Uuid::new_v4().to_string(), "admin", hash],
)?;
if generated {
println!("╔══════════════════════════════════════════════════════════╗");
println!(" Compte admin créé — mot de passe initial (affiché une");
println!(" seule fois, changez-le après la première connexion) :");
println!(" utilisateur: admin");
println!(" mot de passe: {password}");
println!("╚══════════════════════════════════════════════════════════╝");
} else {
println!(
"Default admin user created (username: admin, password: ADMIN_INITIAL_PASSWORD)"
);
}
Ok(())
}
// ==================== Users ====================
pub fn find_user_by_username(&self, username: &str) -> Option<User> {
self.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
user_from_row,
)
.optional()
.ok()
.flatten()
}
pub fn find_user_by_id(&self, user_id: &str) -> Option<User> {
self.lock()
.query_row("SELECT * FROM users WHERE id = ?", [user_id], user_from_row)
.optional()
.ok()
.flatten()
}
/// Vérifie les identifiants. Le hachage bcrypt (coûteux) est fait hors
/// du verrou de la base : à appeler depuis `spawn_blocking`.
pub fn verify_credentials(&self, username: &str, password: &str) -> Option<User> {
let (user, stored) = self
.lock()
.query_row(
"SELECT * FROM users WHERE username = ?",
[username],
|row| Ok((user_from_row(row)?, row.get::<_, String>("password_hash")?)),
)
.optional()
.ok()
.flatten()?;
if !password::verify(password, &stored) {
return None;
}
// Migration paresseuse : les anciens hachages SHA-256 sont refaits en
// bcrypt à la connexion (seul moment où le mot de passe est connu).
if password::is_legacy(&stored)
&& let Ok(hash) = password::hash(password)
{
self.update_password_hash(&user.id, &hash);
println!("[Auth] Migrated legacy password hash for user: {username}");
}
Some(user)
}
pub fn update_password_hash(&self, user_id: &str, hash: &str) {
let _ = self.lock().execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![hash, user_id],
);
}
/// Crée un utilisateur ; [password_hash] est déjà calculé par l'appelant.
pub fn create_user(
&self,
username: &str,
password_hash: &str,
is_admin: bool,
) -> DbResult<User> {
let id = uuid::Uuid::new_v4().to_string();
self.lock().execute(
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
params![id, username, password_hash, is_admin as i64],
)?;
Ok(User {
id,
username: username.to_string(),
is_admin,
created_at: DartDateTime::now_local(),
})
}
pub fn get_all_users(&self) -> Vec<User> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare("SELECT * FROM users ORDER BY username ASC") else {
return Vec::new();
};
stmt.query_map([], user_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn update_user_admin_status(&self, user_id: &str, is_admin: bool) -> DbResult<()> {
self.lock().execute(
"UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![is_admin as i64, user_id],
)?;
Ok(())
}
pub fn delete_user(&self, user_id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM users WHERE id = ?", [user_id])?;
Ok(())
}
// ==================== Sessions ====================
pub fn create_session(&self, user_id: &str) -> DbResult<Session> {
let token = uuid::Uuid::new_v4().to_string();
let expires_at = DartDateTime::now_local().add(Duration::days(7));
self.lock().execute(
"INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
params![
uuid::Uuid::new_v4().to_string(),
user_id,
token,
expires_at.to_iso()
],
)?;
Ok(Session {
user_id: user_id.to_string(),
token,
expires_at,
})
}
/// Session valide pour [token] ; une session expirée est supprimée.
pub fn find_session_by_token(&self, token: &str) -> Option<Session> {
let session = self
.lock()
.query_row("SELECT * FROM sessions WHERE token = ?", [token], |row| {
Ok(Session {
user_id: row.get("user_id")?,
token: row.get("token")?,
expires_at: parse_date(row.get("expires_at")?),
})
})
.optional()
.ok()
.flatten()?;
if session.is_expired() {
self.delete_session(token);
return None;
}
Some(session)
}
pub fn delete_session(&self, token: &str) {
let _ = self
.lock()
.execute("DELETE FROM sessions WHERE token = ?", [token]);
}
pub fn clean_expired_sessions(&self) {
let _ = self.lock().execute(
"DELETE FROM sessions WHERE expires_at < ?",
[now_local_iso()],
);
}
// ==================== Playlists ====================
pub fn get_playlists(&self, user_id: &str) -> Vec<Playlist> {
let conn = self.lock();
let Ok(mut stmt) =
conn.prepare("SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC")
else {
return Vec::new();
};
stmt.query_map([user_id], playlist_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_playlist_by_id(&self, id: &str) -> Option<Playlist> {
self.lock()
.query_row(
"SELECT * FROM playlists WHERE id = ?",
[id],
playlist_from_row,
)
.optional()
.ok()
.flatten()
}
#[allow(clippy::too_many_arguments)]
pub fn create_playlist(
&self,
user_id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Playlist> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
let now_iso = now.to_iso();
self.lock().execute(
"INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, name, server_url, username, password, dns, now_iso, now_iso],
)?;
Ok(Playlist {
id,
user_id: user_id.into(),
name: name.into(),
server_url: server_url.into(),
username: username.into(),
password: password.into(),
dns: dns.map(str::to_string),
created_at: now,
updated_at: now,
})
}
pub fn update_playlist(
&self,
id: &str,
name: &str,
server_url: &str,
username: &str,
password: &str,
dns: Option<&str>,
) -> DbResult<Option<Playlist>> {
self.lock().execute(
"UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ?",
params![name, server_url, username, password, dns, now_local_iso(), id],
)?;
Ok(self.get_playlist_by_id(id))
}
pub fn delete_playlist(&self, id: &str) -> DbResult<()> {
self.lock()
.execute("DELETE FROM playlists WHERE id = ?", [id])?;
Ok(())
}
// ==================== User Settings ====================
pub fn get_user_settings(&self, user_id: &str) -> Option<String> {
self.lock()
.query_row(
"SELECT settings_json FROM user_settings WHERE user_id = ?",
[user_id],
|r| r.get(0),
)
.optional()
.ok()
.flatten()
}
pub fn update_user_settings(&self, user_id: &str, json: &str) -> DbResult<()> {
self.lock().execute(
"INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at",
params![user_id, json, now_local_iso()],
)?;
Ok(())
}
/// Réglage GPU NVIDIA, global au serveur : celui des derniers réglages
/// enregistrés, tous utilisateurs confondus.
pub fn is_nvidia_gpu_enabled(&self) -> bool {
let settings: Option<String> = self
.lock()
.query_row(
"SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1",
[],
|r| r.get(0),
)
.optional()
.ok()
.flatten();
settings.is_some_and(|json| GPU_SETTING.is_match(&json))
}
// ==================== TV Recordings ====================
pub fn create_recording(
&self,
user_id: &str,
channel_id: &str,
stream_url: &str,
title: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> DbResult<Recording> {
let id = uuid::Uuid::new_v4().to_string();
let now = DartDateTime::now_local();
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du
// scheduler et la déduplication des season passes reposent sur ce
// format unique.
let start = DartDateTime::from_utc(start);
let end = DartDateTime::from_utc(end);
self.lock().execute(
"INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
id,
user_id,
channel_id,
stream_url,
title,
start.to_iso(),
end.to_iso(),
now.to_iso(),
now.to_iso()
],
)?;
Ok(Recording {
id,
user_id: user_id.into(),
channel_id: channel_id.into(),
stream_url: stream_url.into(),
title: title.into(),
start_time: start,
end_time: end,
status: "scheduled".into(),
file_path: None,
error_reason: None,
created_at: now,
updated_at: now,
})
}
fn query_recordings(&self, sql: &str, args: &[&str]) -> Vec<Recording> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), recording_from_row)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn get_all_recordings(&self) -> Vec<Recording> {
self.query_recordings("SELECT * FROM tv_recordings ORDER BY start_time ASC", &[])
}
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
pub fn get_pending_recordings(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') ORDER BY start_time ASC",
&[],
)
}
/// Enregistrements terminés, du plus ancien au plus récent (rotation disque).
pub fn get_finished_recordings_oldest_first(&self) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') ORDER BY start_time ASC",
&[],
)
}
pub fn get_user_recordings(&self, user_id: &str) -> Vec<Recording> {
self.query_recordings(
"SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC",
&[user_id],
)
}
pub fn get_recording_by_id(&self, id: &str) -> Option<Recording> {
self.query_recordings("SELECT * FROM tv_recordings WHERE id = ?", &[id])
.into_iter()
.next()
}
/// Met à jour le statut et éventuellement le chemin d'un enregistrement.
///
/// [clear_error] efface le motif d'erreur au lieu de conserver l'ancien :
/// utile quand un enregistrement précédemment en échec est relancé.
pub fn update_recording_status(
&self,
id: &str,
status: &str,
file_path: Option<&str>,
error_reason: Option<&str>,
clear_error: bool,
) {
let error_expr = if clear_error {
"?"
} else {
"COALESCE(?, error_reason)"
};
let sql = format!(
"UPDATE tv_recordings SET status = ?, file_path = COALESCE(?, file_path), error_reason = {error_expr}, updated_at = ? WHERE id = ?"
);
if let Err(e) = self.lock().execute(
&sql,
params![status, file_path, error_reason, now_local_iso(), id],
) {
println!("[DB] Mise à jour du statut de {id} impossible: {e}");
}
}
/// Réaligne la fenêtre d'un enregistrement planifié (programme déplacé
/// dans l'EPG depuis sa création par un season pass).
pub fn update_recording_window(&self, id: &str, start: DateTime<Utc>, end: DateTime<Utc>) {
let _ = self.lock().execute(
"UPDATE tv_recordings SET start_time = ?, end_time = ?, updated_at = ? WHERE id = ?",
params![utc_iso(start), utc_iso(end), now_local_iso(), id],
);
}
/// Supprime l'enregistrement de la base (pas le fichier).
pub fn delete_recording(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM tv_recordings WHERE id = ?", [id]);
}
// ==================== Season Passes ====================
pub fn create_season_pass(
&self,
user_id: &str,
show_title: &str,
channel_id: &str,
stream_url: &str,
match_mode: &str,
) -> DbResult<Map<String, Value>> {
let id = uuid::Uuid::new_v4().to_string();
let now = now_local_iso();
self.lock().execute(
"INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
params![id, user_id, show_title, channel_id, stream_url, match_mode, now],
)?;
let mut map = Map::new();
map.insert("id".into(), Value::from(id));
map.insert("user_id".into(), Value::from(user_id));
map.insert("show_title".into(), Value::from(show_title));
map.insert("channel_id".into(), Value::from(channel_id));
map.insert("stream_url".into(), Value::from(stream_url));
map.insert("match_mode".into(), Value::from(match_mode));
map.insert("enabled".into(), Value::from(1));
map.insert("created_at".into(), Value::from(now));
Ok(map)
}
fn query_json(&self, sql: &str, args: &[&str]) -> Vec<Map<String, Value>> {
let conn = self.lock();
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_json)
.map(|rows| rows.filter_map(Result::ok).collect())
.unwrap_or_default()
}
/// Season Passes actifs (pour le scheduler).
pub fn get_all_season_passes(&self) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC",
&[],
)
}
pub fn get_season_passes_for_user(&self, user_id: &str) -> Vec<Map<String, Value>> {
self.query_json(
"SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC",
&[user_id],
)
}
pub fn get_season_pass_by_id(&self, id: &str) -> Option<Map<String, Value>> {
self.query_json("SELECT * FROM season_passes WHERE id = ?", &[id])
.into_iter()
.next()
}
pub fn delete_season_pass(&self, id: &str) {
let _ = self
.lock()
.execute("DELETE FROM season_passes WHERE id = ?", [id]);
}
/// Enregistrement existant pour cet épisode (déduplication des season
/// passes) : même titre, début à ±[tolerance] près.
///
/// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
/// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
pub fn find_recording_for_episode(
&self,
title: &str,
start: DateTime<Utc>,
tolerance: Duration,
) -> Option<Recording> {
let low = utc_iso(start - tolerance);
let high = utc_iso(start + tolerance);
self.query_recordings(
"SELECT * FROM tv_recordings WHERE LOWER(title) = LOWER(?) AND start_time BETWEEN ? AND ? AND status NOT IN ('failed', 'cancelled') LIMIT 1",
&[title, &low, &high],
)
.into_iter()
.next()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_db() -> (AppDatabase, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("xf-test-{}.db", uuid::Uuid::new_v4()));
let db = AppDatabase::open(path.to_str().unwrap()).unwrap();
(db, path)
}
#[test]
fn sessions_et_cascade() {
let (db, _path) = temp_db();
let hash = password::hash("password1").unwrap();
let user = db.create_user("bob", &hash, false).unwrap();
let session = db.create_session(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_some());
assert!(db.verify_credentials("bob", "password1").is_some());
assert!(db.verify_credentials("bob", "nope").is_none());
// ON DELETE CASCADE actif : la session disparaît avec l'utilisateur.
db.delete_user(&user.id).unwrap();
assert!(db.find_session_by_token(&session.token).is_none());
}
#[test]
fn migration_paresseuse_du_sha256() {
let (db, _path) = temp_db();
let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918";
let user = db.create_user("old", legacy, false).unwrap();
assert!(db.verify_credentials("old", "admin").is_some());
let stored: String = db
.lock()
.query_row(
"SELECT password_hash FROM users WHERE id = ?",
[&user.id],
|r| r.get(0),
)
.unwrap();
assert!(!password::is_legacy(&stored));
assert!(db.verify_credentials("old", "admin").is_some());
}
#[test]
fn deduplication_des_episodes_a_deux_minutes_pres() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let start = DartDateTime::parse("2026-08-11T18:00:00Z")
.unwrap()
.to_utc();
let end = start + Duration::hours(1);
db.create_recording(&user.id, "1", "/api/live/1.ts", "Journal", start, end)
.unwrap();
assert!(
db.find_recording_for_episode(
"JOURNAL",
start + Duration::seconds(90),
Duration::minutes(2)
)
.is_some()
);
assert!(
db.find_recording_for_episode(
"Journal",
start + Duration::minutes(5),
Duration::minutes(2)
)
.is_none()
);
let rec = db.get_all_recordings().pop().unwrap();
assert_eq!(rec.start_time.to_iso(), "2026-08-11T18:00:00.000Z");
assert_eq!(rec.status, "scheduled");
}
#[test]
fn statut_et_motif_d_erreur() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
let now = Utc::now();
let rec = db
.create_recording(&user.id, "1", "/x", "T", now, now + Duration::hours(1))
.unwrap();
db.update_recording_status(&rec.id, "failed", None, Some("boom"), false);
db.update_recording_status(&rec.id, "failed", Some("/f.mkv"), None, false);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason.as_deref(), Some("boom"));
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
db.update_recording_status(&rec.id, "recording", None, None, true);
let r = db.get_recording_by_id(&rec.id).unwrap();
assert_eq!(r.error_reason, None);
assert_eq!(r.file_path.as_deref(), Some("/f.mkv"));
}
#[test]
fn reglage_gpu() {
let (db, _path) = temp_db();
let user = db.create_user("u", "x", false).unwrap();
assert!(!db.is_nvidia_gpu_enabled());
db.update_user_settings(&user.id, r#"{"enable_nvidia_gpu": true}"#)
.unwrap();
assert!(db.is_nvidia_gpu_enabled());
}
#[test]
fn mot_de_passe_genere() {
let p = generate_password(16);
assert_eq!(p.len(), 16);
assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Requête/réponse HTTP internes et petits utilitaires partagés.
//!
//! Le routage reproduit la `Cascade` shelf de l'ancien serveur : chaque
//! étage (API, proxy Xtream, streaming, fichiers statiques) répond ou passe
//! la main par un 404/405. Le corps des requêtes est lu une fois pour toutes
//! en amont, ce qui permet de présenter la même requête à plusieurs étages.
use crate::models::User;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use bytes::Bytes;
use serde_json::Value;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
pub type Resp = axum::response::Response;
#[derive(Clone)]
pub struct Req {
pub method: Method,
/// Chemin sans `/` initial, tel que reçu (encodé), comme `request.url.path`.
pub path: String,
/// Query string brute, sans `?`.
pub query: String,
pub headers: HeaderMap,
pub body: Bytes,
pub remote: Option<SocketAddr>,
/// Renseignés par le middleware d'authentification.
pub user: Option<User>,
pub user_id: Option<String>,
}
impl Req {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
/// Paramètres de query décodés (`+` = espace) ; la dernière occurrence
/// d'une clé l'emporte, comme `Uri.queryParameters`.
pub fn query_params(&self) -> HashMap<String, String> {
url::form_urlencoded::parse(self.query.as_bytes())
.into_owned()
.collect()
}
pub fn query_param(&self, key: &str) -> Option<String> {
self.query_params().remove(key)
}
pub fn is_get(&self) -> bool {
self.method == Method::GET || self.method == Method::HEAD
}
pub fn body_str(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
// ==================== Réponses ====================
pub fn response(status: u16, body: impl Into<Body>) -> Resp {
let mut resp = Resp::new(body.into());
*resp.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
resp
}
/// Corps texte (`text/plain; charset=utf-8`, le défaut de shelf).
pub fn text(status: u16, body: impl Into<String>) -> Resp {
let mut resp = response(status, body.into());
set_header(&mut resp, "content-type", "text/plain; charset=utf-8");
resp
}
pub fn empty(status: u16) -> Resp {
response(status, Body::empty())
}
/// JSON avec l'en-tête `Content-Type: application/json`.
pub fn json(status: u16, value: &Value) -> Resp {
raw_json(status, value.to_string())
}
pub fn raw_json(status: u16, body: String) -> Resp {
let mut resp = response(status, body);
set_header(&mut resp, "content-type", "application/json");
resp
}
/// JSON sans en-tête de type (les `Response.forbidden(jsonEncode(...))` de
/// l'ancien serveur, servis en `text/plain`).
pub fn json_as_text(status: u16, value: &Value) -> Resp {
text(status, value.to_string())
}
pub fn redirect(location: &str) -> Resp {
let mut resp = text(302, format!("Moved to {location}"));
set_header(&mut resp, "location", location);
resp
}
pub fn set_header(resp: &mut Resp, name: &str, value: &str) {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
resp.headers_mut().insert(name, value);
}
}
pub fn with_headers(mut resp: Resp, headers: &[(&str, &str)]) -> Resp {
for (name, value) in headers {
set_header(&mut resp, name, value);
}
resp
}
/// La `Cascade` passe à l'étage suivant sur un 404 ou un 405.
pub fn is_miss(resp: &Resp) -> bool {
matches!(resp.status().as_u16(), 404 | 405)
}
pub fn not_found() -> Resp {
text(404, "Not Found")
}
// ==================== Authentification ====================
/// Jeton de session : en-tête `Authorization: Bearer …` ou cookie `session`.
pub fn extract_token(req: &Req) -> Option<String> {
if let Some(auth) = req.header("authorization")
&& let Some(token) = auth.strip_prefix("Bearer ")
{
return Some(token.to_string());
}
let cookie = req.header("cookie")?;
cookie
.split(';')
.map(str::trim)
.find_map(|part| part.strip_prefix("session=").map(str::to_string))
}
// ==================== Adresse du client ====================
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
/// docker-compose parle depuis le réseau Docker). TRUSTED_PROXIES (liste
/// d'IP séparées par des virgules) restreint la liste.
pub fn is_trusted_proxy(address: &IpAddr, trusted: &[String]) -> bool {
if !trusted.is_empty() {
return trusted.iter().any(|t| t == &address.to_string());
}
match address {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
}
}
/// Adresse socket au format texte (IPv4 « nue » pour les adresses mappées).
pub fn socket_ip(req: &Req) -> Option<IpAddr> {
req.remote.map(|addr| match addr.ip() {
IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(v6)),
ip => ip,
})
}
/// IP réelle du client.
///
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
/// contourner le rate limit global comme la limite de tentatives de login.
pub fn client_ip(req: &Req, trusted: &[String]) -> String {
let socket = socket_ip(req);
if let (Some(forwarded), Some(ip)) = (req.header("x-forwarded-for"), socket)
&& !forwarded.is_empty()
&& is_trusted_proxy(&ip, trusted)
{
return forwarded.split(',').next().unwrap_or("").trim().to_string();
}
socket
.map(|ip| ip.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Segments d'un sous-chemin monté (`""` → aucun segment).
pub fn segments(rest: &str) -> Vec<&str> {
let rest = rest.trim_start_matches('/');
if rest.is_empty() {
Vec::new()
} else {
rest.split('/').collect()
}
}
/// Sous-chemin d'un montage shelf (`mount('/api/x')`) : `Some("")` pour
/// `api/x` ou `api/x/`, `Some("a/b")` pour `api/x/a/b`, `None` sinon.
pub fn mounted<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
if path == prefix {
return Some("");
}
path.strip_prefix(prefix)?.strip_prefix('/')
}
#[cfg(test)]
mod tests {
use super::*;
fn req_with(headers: &[(&str, &str)], remote: &str) -> Req {
let mut map = HeaderMap::new();
for (k, v) in headers {
map.insert(
HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
Req {
method: Method::GET,
path: String::new(),
query: String::new(),
headers: map,
body: Bytes::new(),
remote: Some(remote.parse().unwrap()),
user: None,
user_id: None,
}
}
#[test]
fn jeton_par_en_tete_ou_cookie() {
assert_eq!(
extract_token(&req_with(&[("authorization", "Bearer abc")], "1.2.3.4:1")),
Some("abc".into())
);
assert_eq!(
extract_token(&req_with(
&[("cookie", "a=1; session=xyz; b=2")],
"1.2.3.4:1"
)),
Some("xyz".into())
);
assert_eq!(extract_token(&req_with(&[], "1.2.3.4:1")), None);
}
#[test]
fn x_forwarded_for_seulement_derriere_un_proxy_de_confiance() {
let direct = req_with(&[("x-forwarded-for", "9.9.9.9")], "8.8.8.8:1");
assert_eq!(client_ip(&direct, &[]), "8.8.8.8");
let proxied = req_with(&[("x-forwarded-for", "9.9.9.9, 10.0.0.1")], "172.18.0.2:1");
assert_eq!(client_ip(&proxied, &[]), "9.9.9.9");
assert_eq!(client_ip(&proxied, &["10.1.1.1".into()]), "172.18.0.2");
}
#[test]
fn montage_shelf() {
assert_eq!(mounted("api/playlists", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/", "api/playlists"), Some(""));
assert_eq!(mounted("api/playlists/x", "api/playlists"), Some("x"));
assert_eq!(mounted("api/playlistsx", "api/playlists"), None);
}
#[test]
fn parametres_de_query() {
let mut req = req_with(&[], "1.2.3.4:1");
req.query = "a=1&b=x+y&a=2&c=%C3%A9".into();
let params = req.query_params();
assert_eq!(params["a"], "2");
assert_eq!(params["b"], "x y");
assert_eq!(params["c"], "é");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Serveur XtremFlow : API REST, proxy Xtream, streaming FFmpeg,
//! enregistrements TV et fichiers de la build web Flutter.
#![allow(clippy::result_large_err)]
mod api;
mod dart_time;
mod db;
mod http;
mod models;
mod server;
mod services;
mod state;
mod util;
use crate::api::epg::{EpgApi, default_panel_xmltv};
use crate::api::proxy::ProxyHandler;
use crate::api::streaming::Streaming;
use crate::db::AppDatabase;
use crate::server::RateLimiter;
use crate::services::cleanup::CleanupService;
use crate::services::ffmpeg_sessions::FfmpegSessionManager;
use crate::services::logo_catalog::LogoCatalog;
use crate::services::recording_scheduler::RecordingScheduler;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::state::AppState;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_XMLTV: &str = "https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz";
struct Args {
port: u16,
path: String,
}
/// `--port 8089` / `-p 8089` / `--port=8089`, `--path /app/web`.
fn parse_args() -> Args {
let mut args = Args {
port: 8089,
path: "/app/web".to_string(),
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < raw.len() {
let (key, inline) = match raw[i].split_once('=') {
Some((k, v)) => (k.to_string(), Some(v.to_string())),
None => (raw[i].clone(), None),
};
let mut value = || {
inline.clone().or_else(|| {
i += 1;
raw.get(i).cloned()
})
};
match key.as_str() {
"--port" | "-p" => {
if let Some(v) = value() {
args.port = v.parse().expect("--port doit être un entier");
}
}
"--path" => {
if let Some(v) = value() {
args.path = v;
}
}
_ => {}
}
i += 1;
}
args
}
fn http_client(redirects: reqwest::redirect::Policy) -> reqwest::Client {
reqwest::Client::builder()
.redirect(redirects)
.user_agent("VLC/3.0.18 LibVLC/3.0.18")
.connect_timeout(Duration::from_secs(30))
.build()
.expect("client HTTP")
}
#[tokio::main]
async fn main() {
let args = parse_args();
let web_path = args.path.clone();
let db_path =
std::env::var("XTREMFLOW_DB_PATH").unwrap_or_else(|_| "/app/data/xtremflow.db".into());
let db = Arc::new(AppDatabase::open(&db_path).expect("ouverture de la base"));
db.seed_admin().expect("création du compte admin");
let http = http_client(reqwest::redirect::Policy::limited(5));
let http_manual = http_client(reqwest::redirect::Policy::none());
// Planificateur d'enregistrements (les season passes résolvent la
// playlist de leur propriétaire à chaque scan).
let scheduler = Arc::new(RecordingScheduler::new(db.clone(), http.clone()));
scheduler.start();
// Sessions FFmpeg de streaming.
let sessions = Arc::new(FfmpegSessionManager::new(
std::env::temp_dir().join("xtremflow_streams"),
));
sessions.init();
let streaming = Arc::new(Streaming::new(sessions.clone(), http.clone()));
// Source XMLTV de repli. EPG_XMLTV_URLS vide = aucun appel sortant : le
// guide se limite alors au panneau de l'abonné.
let xmltv_urls: Vec<String> = std::env::var("EPG_XMLTV_URLS")
.unwrap_or_else(|_| DEFAULT_XMLTV.to_string())
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect();
if xmltv_urls.is_empty() {
println!("[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)");
} else {
println!("[EPG] Repli XMLTV : {} source(s)", xmltv_urls.len());
}
let xmltv = (!xmltv_urls.is_empty()).then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(xmltv_urls),
http.clone(),
))
});
let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone()));
// Purge des temporaires. Jamais le dossier temporaire système en
// récursif : il contient les sessions HLS.
let cleanup = Arc::new(CleanupService::new());
cleanup.add_target("/app/data/logs");
cleanup.add_target("/app/data/tmp");
cleanup.start();
// Libs de lecture servies depuis l'image : si elles manquent, le lecteur
// échoue côté navigateur sans que rien ne l'ait signalé. On le dit ici.
for lib in ["hls.min.js", "mpegts.min.js"] {
if !std::path::Path::new(&web_path)
.join("vendor")
.join(lib)
.exists()
{
println!(
"[Server] ATTENTION: web/vendor/{lib} absent de {web_path} — la lecture échouera dans le navigateur."
);
}
}
let versioned = util::asset_versioning::build_versioned_entry_points(&web_path);
let mut keys: Vec<&String> = versioned.keys().collect();
keys.sort();
println!(
"[Server] Assets versionnés : {}",
keys.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let state = Arc::new(AppState {
db: db.clone(),
web_path: web_path.clone(),
versioned,
http: http.clone(),
http_manual,
scheduler: scheduler.clone(),
streaming,
proxy: ProxyHandler::new(),
epg,
logo_catalog: Arc::new(LogoCatalog::new(http.clone())),
cleanup,
rate_limit: RateLimiter::new(600),
login_rate_limit: RateLimiter::new(10),
allowed_origin: std::env::var("ALLOWED_ORIGIN")
.ok()
.filter(|o| !o.is_empty()),
trusted_proxies: std::env::var("TRUSTED_PROXIES")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
});
// Sessions expirées purgées toutes les heures.
let db_clean = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
interval.tick().await;
loop {
interval.tick().await;
db_clean.clean_expired_sessions();
println!("Cleaned expired sessions");
}
});
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
// enregistrements (kill FFmpeg, fusion des parties, statut en base),
// puis les sessions de streaming, puis sortir.
tokio::spawn(shutdown_on_signal(scheduler, sessions));
let app = axum::Router::new()
.fallback(server::entry)
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("écoute sur {addr} impossible : {e}"));
println!("Server started on port {}", args.port);
println!("Serving static files from: {web_path}");
println!("REST API available at: /api/auth/* and /api/playlists/*");
println!("Xtream proxy available at: /api/xtream/* (SSRF Protected)");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("serveur HTTP");
}
async fn shutdown_on_signal(
scheduler: Arc<RecordingScheduler>,
sessions: Arc<FfmpegSessionManager>,
) {
let signal = wait_for_signal().await;
println!("[Server] {signal} reçu, arrêt en cours…");
scheduler.shutdown().await;
sessions.kill_all();
// Laisser SIGTERM partir vers les FFmpeg avant de sortir.
tokio::time::sleep(Duration::from_millis(200)).await;
std::process::exit(0);
}
#[cfg(unix)]
async fn wait_for_signal() -> &'static str {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("SIGTERM");
let mut int = signal(SignalKind::interrupt()).expect("SIGINT");
tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
}
}
#[cfg(not(unix))]
async fn wait_for_signal() -> &'static str {
let _ = tokio::signal::ctrl_c().await;
"SIGINT"
}
+121
View File
@@ -0,0 +1,121 @@
//! Modèles de données, sérialisés exactement comme l'ancien serveur Dart
//! (noms de champs, formats de dates) : le front Flutter n'a pas changé.
use crate::dart_time::DartDateTime;
use serde_json::{Map, Value, json};
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub username: String,
pub is_admin: bool,
pub created_at: DartDateTime,
}
impl User {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"username": self.username,
"isAdmin": self.is_admin,
"createdAt": self.created_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Playlist {
pub id: String,
pub user_id: String,
pub name: String,
pub server_url: String,
pub username: String,
pub password: String,
pub dns: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Playlist {
pub fn to_json(&self) -> Value {
json!({
"id": self.id,
"userId": self.user_id,
"name": self.name,
"serverUrl": self.server_url,
"username": self.username,
// Le mot de passe Xtream n'est jamais exposé aux clients : le
// serveur injecte les identifiants lui-même (voir xtream_api).
"password": "",
"dns": self.dns,
"createdAt": self.created_at.to_iso(),
"updatedAt": self.updated_at.to_iso(),
})
}
}
#[derive(Clone, Debug)]
pub struct Session {
pub user_id: String,
pub token: String,
pub expires_at: DartDateTime,
}
impl Session {
pub fn is_expired(&self) -> bool {
chrono::Utc::now() > self.expires_at.to_utc()
}
}
/// Configuration Xtream utilisée par les proxys et le streaming.
#[derive(Clone, Debug)]
pub struct PlaylistConfig {
/// URL du serveur Xtream (sans `/` final).
pub dns: String,
pub username: String,
pub password: String,
}
#[derive(Clone, Debug)]
pub struct Recording {
pub id: String,
pub user_id: String,
pub channel_id: String,
pub stream_url: String,
pub title: String,
pub start_time: DartDateTime,
pub end_time: DartDateTime,
/// 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
pub status: String,
pub file_path: Option<String>,
pub error_reason: Option<String>,
pub created_at: DartDateTime,
pub updated_at: DartDateTime,
}
impl Recording {
pub fn to_map(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("id".into(), json!(self.id));
map.insert("user_id".into(), json!(self.user_id));
map.insert("channel_id".into(), json!(self.channel_id));
map.insert("stream_url".into(), json!(self.stream_url));
map.insert("title".into(), json!(self.title));
map.insert("start_time".into(), json!(self.start_time.to_iso()));
map.insert("end_time".into(), json!(self.end_time.to_iso()));
map.insert("status".into(), json!(self.status));
map.insert("file_path".into(), json!(self.file_path));
map.insert("error_reason".into(), json!(self.error_reason));
map.insert("created_at".into(), json!(self.created_at.to_iso()));
map.insert("updated_at".into(), json!(self.updated_at.to_iso()));
map
}
pub fn start_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.start_time.to_utc()
}
pub fn end_utc(&self) -> chrono::DateTime<chrono::Utc> {
self.end_time.to_utc()
}
}
+368
View File
@@ -0,0 +1,368 @@
//! Point d'entrée HTTP : middlewares puis cascade API → proxy Xtream →
//! streaming → fichiers statiques, à l'identique de l'ancien serveur.
use crate::api::{account, admin, logo, player_log, recordings, static_files, xtream_api};
use crate::dart_time::now_local_iso;
use crate::http::{self, Req, Resp, client_ip, extract_token, is_miss, mounted, socket_ip};
use crate::state::SharedState;
use crate::util::log_redactor::redact_url;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::Method;
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Corps de requête maximal lu en mémoire.
const MAX_BODY: usize = 16 * 1024 * 1024;
/// Limiteur glissant par IP sur une minute.
pub struct RateLimiter {
limit: usize,
history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl RateLimiter {
pub fn new(limit: usize) -> Self {
let history: Arc<Mutex<HashMap<String, VecDeque<Instant>>>> = Arc::default();
// Ménage toutes les 5 minutes (pas de fuite mémoire par IP).
let h = history.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
interval.tick().await;
loop {
interval.tick().await;
h.lock().unwrap().retain(|_, times| {
times.retain(|t| t.elapsed() < Duration::from_secs(120));
!times.is_empty()
});
}
});
Self { limit, history }
}
/// Enregistre une requête ; `false` si la limite est atteinte.
pub fn allow(&self, ip: &str) -> bool {
let mut history = self.history.lock().unwrap();
let times = history.entry(ip.to_string()).or_default();
while times
.front()
.is_some_and(|t| t.elapsed() >= Duration::from_secs(60))
{
times.pop_front();
}
if times.len() >= self.limit {
return false;
}
times.push_back(Instant::now());
true
}
}
/// Handler axum unique : lit le corps puis applique la chaîne complète.
pub async fn entry(
State(state): State<SharedState>,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
request: Request,
) -> Resp {
let (parts, body) = request.into_parts();
let body = match axum::body::to_bytes(body, MAX_BODY).await {
Ok(b) => b,
Err(_) => return http::text(413, "Request Entity Too Large"),
};
let req = Req {
method: parts.method,
path: parts.uri.path().trim_start_matches('/').to_string(),
query: parts.uri.query().unwrap_or("").to_string(),
headers: parts.headers,
body,
remote: Some(remote),
user: None,
user_id: None,
};
handle(&state, req).await
}
pub async fn handle(state: &SharedState, req: Req) -> Resp {
// Journal masqué : le chemin de /api/xtream/<url> porte les
// identifiants Xtream en clair.
let started = Instant::now();
let log_target = if req.query.is_empty() {
format!("/{}", req.path)
} else {
format!("/{}?{}", req.path, req.query)
};
let method = req.method.clone();
let mut resp = security_chain(state, req).await;
add_security_headers(&mut resp);
println!(
"{} {} {method} {} ({}ms)",
now_local_iso(),
resp.status().as_u16(),
redact_url(&log_target),
started.elapsed().as_millis()
);
resp
}
fn add_security_headers(resp: &mut Resp) {
for (name, value) in [
("x-content-type-options", "nosniff"),
// SAMEORIGIN : les iframes player.html restent intégrables.
("x-frame-options", "SAMEORIGIN"),
("x-xss-protection", "1; mode=block"),
(
"strict-transport-security",
"max-age=63072000; includeSubDomains; preload",
),
("referrer-policy", "strict-origin-when-cross-origin"),
// Report-Only : Flutter Web (CanvasKit/wasm) et google_fonts peuvent
// casser sous une CSP appliquée.
(
"content-security-policy-report-only",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://fonts.gstatic.com",
),
] {
http::set_header(resp, name, value);
}
}
/// Chemins classiques des scanners de vulnérabilités.
const HONEYPOT_PATHS: [&str; 9] = [
"/admin/phpmyadmin",
"/phpmyadmin",
"/wp-admin",
"/wp-login.php",
"/.env",
"/config.php",
"/api/.env",
"/console",
"/actuator/health",
];
async fn security_chain(state: &SharedState, req: Req) -> Resp {
// Honeypot : chemin exact ou préfixe de segment (pas une sous-chaîne
// quelconque, qui bloquait des URL proxifiées légitimes).
let path = format!("/{}", req.path);
for trap in HONEYPOT_PATHS {
if path == trap || path.starts_with(&format!("{trap}/")) {
println!(
"SECURITY ALERT: Honeypot triggered by {} on path: {path}",
client_ip(&req, &state.trusted_proxies)
);
return http::text(403, "Access Denied");
}
}
// Limite globale : 600 req/min/IP (une grille de chaînes déclenche une
// requête de logo et une de guide par tuile, derrière une seule IP de
// reverse proxy).
let ip = client_ip(&req, &state.trusted_proxies);
if ip != "unknown" && ip != "127.0.0.1" && !state.rate_limit.allow(&ip) {
println!("SECURITY WARN: Rate limit exceeded for {ip}");
return http::text(429, "Too Many Requests");
}
cors(state, req).await
}
/// CORS : l'app est servie en même origine ; les en-têtes ne servent qu'au
/// développement (localhost) ou à ALLOWED_ORIGIN. L'origine n'est renvoyée
/// que si elle est autorisée — jamais de joker.
async fn cors(state: &SharedState, req: Req) -> Resp {
let origin = req.header("origin").map(str::to_string);
let allowed = origin.as_deref().is_some_and(|o| {
if state
.allowed_origin
.as_deref()
.is_some_and(|a| !a.is_empty() && a == o)
{
return true;
}
url::Url::parse(o)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|h| h == "localhost" || h == "127.0.0.1")
});
let headers: Vec<(&str, String)> = match (&origin, allowed) {
(Some(o), true) => vec![
("access-control-allow-origin", o.clone()),
(
"access-control-allow-methods",
"GET, POST, PUT, DELETE, OPTIONS".into(),
),
(
"access-control-allow-headers",
"Origin, Content-Type, Accept, Authorization, Range".into(),
),
(
"access-control-expose-headers",
"Content-Length, Content-Range".into(),
),
("access-control-allow-credentials", "true".into()),
("vary", "Origin".into()),
],
_ => Vec::new(),
};
let mut resp = if req.method == Method::OPTIONS {
http::text(200, "")
} else {
cascade(state, req).await
};
for (name, value) in headers {
http::set_header(&mut resp, name, &value);
}
resp
}
/// Cascade shelf : un 404/405 passe la main à l'étage suivant.
async fn cascade(state: &SharedState, req: Req) -> Resp {
let resp = api_router(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = state.proxy.handle(state, &req).await;
if !is_miss(&resp) {
return resp;
}
let resp = protected_streaming(state, &req).await;
if !is_miss(&resp) {
return resp;
}
static_files::handle(state, &req).await
}
/// Middleware d'authentification : session valide exigée, utilisateur
/// attaché à la requête.
fn authenticate(state: &SharedState, req: &Req) -> Result<Req, Resp> {
let Some(token) = extract_token(req) else {
return Err(http::text(401, "Unauthorized"));
};
let Some(session) = state.db.find_session_by_token(&token) else {
return Err(http::text(401, "Invalid or expired session"));
};
let mut authed = req.clone();
authed.user = state.db.find_user_by_id(&session.user_id);
authed.user_id = Some(session.user_id);
Ok(authed)
}
macro_rules! authed {
($state:expr, $req:expr) => {
match authenticate($state, $req) {
Ok(r) => r,
Err(resp) => return resp,
}
};
}
async fn api_router(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
if let Some(rest) = mounted(path, "api/auth") {
// Protection brute force : 10 tentatives de login/min/IP, et 300 ms
// de pénalité par échec.
let is_login = req.method == Method::POST && path.ends_with("login");
if is_login {
let ip = client_ip(req, &state.trusted_proxies);
if !state.login_rate_limit.allow(&ip) {
println!("SECURITY WARN: Login rate limit exceeded for {ip}");
return http::text(429, "Too many login attempts. Try again later.");
}
}
let resp = account::auth(state, req, rest).await;
if is_login && resp.status().as_u16() == 401 {
tokio::time::sleep(Duration::from_millis(300)).await;
}
return resp;
}
// Passerelle Xtream : identifiants injectés côté serveur.
if mounted(path, "api/xtream-api").is_some() {
let req = authed!(state, req);
return xtream_api::handle(state, &req).await;
}
if let Some(rest) = mounted(path, "api/playlists") {
let req = authed!(state, req);
return account::playlists(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/users") {
let req = authed!(state, req);
return account::users(state, &req, rest).await;
}
if let Some(rest) = mounted(path, "api/settings") {
let req = authed!(state, req);
return account::settings(state, &req, rest);
}
// NB : /api/recordings/stream/* traverse ce routeur (404) et atteint le
// streaming plus bas dans la cascade.
if let Some(rest) = mounted(path, "api/recordings") {
let req = authed!(state, req);
return recordings::recordings(state, &req, rest).await;
}
// Logos (le cookie de session accompagne les requêtes d'image).
if path == "api/logo" && req.is_get() {
let req = authed!(state, req);
return logo::handle(state, &req).await;
}
// Incidents de lecture remontés par le lecteur web.
if path == "api/player-log" && req.method == Method::POST {
let _ = authed!(state, req);
return player_log::handle(req);
}
if let Some(rest) = mounted(path, "api/epg") {
let req = authed!(state, req);
return match (req.is_get(), http::segments(rest).as_slice()) {
(true, [channel_id]) => {
let playlist = state.get_playlist(&req);
state.epg.handle(playlist, channel_id).await
}
_ => http::not_found(),
};
}
if let Some(rest) = mounted(path, "api/season-passes") {
let req = authed!(state, req);
return recordings::season_passes(state, &req, rest);
}
if let Some(rest) = mounted(path, "api/admin") {
let req = authed!(state, req);
return admin::admin(state, &req, rest).await;
}
http::not_found()
}
/// Streaming : le cookie de session HttpOnly est accepté (hls.js ne peut pas
/// envoyer d'en-tête Authorization) ; les requêtes FFmpeg locales du
/// planificateur (`http://localhost:8089/api/live/<id>.ts`, sans cookie)
/// passent sans authentification.
async fn protected_streaming(state: &SharedState, req: &Req) -> Resp {
let path = req.path.as_str();
let is_streaming = path.starts_with("api/live")
|| path.starts_with("api/vod")
|| path.starts_with("api/recordings/stream");
if !is_streaming {
return http::not_found();
}
let loopback = socket_ip(req).is_some_and(|ip| ip.is_loopback());
let via_proxy = req.headers.contains_key("x-forwarded-for");
if !(loopback && !via_proxy) {
let valid =
extract_token(req).is_some_and(|t| state.db.find_session_by_token(&t).is_some());
if !valid {
return http::text(401, "Unauthorized");
}
}
let streaming = state.streaming.clone();
if let Some(rest) = mounted(path, "api/live") {
return streaming.live(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/vod") {
return streaming.vod(state, req, rest).await;
}
if let Some(rest) = mounted(path, "api/recordings/stream") {
return streaming.recording(state, req, rest).await;
}
http::not_found()
}
+135
View File
@@ -0,0 +1,135 @@
//! Purge périodique des fichiers temporaires et des logs de plus de 24 h.
//!
//! Ne JAMAIS cibler le dossier temporaire système en récursif : il contient
//! le dossier des sessions HLS — les fichiers de plus de 24 h y seraient
//! supprimés aveuglément.
use crate::dart_time::now_local_iso;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
pub struct CleanupService {
targets: Mutex<Vec<PathBuf>>,
max_age: Duration,
}
impl CleanupService {
pub fn new() -> Self {
Self {
targets: Mutex::new(Vec::new()),
max_age: Duration::from_secs(24 * 3600),
}
}
/// Ajoute un dossier surveillé (créé s'il manque).
pub fn add_target(&self, dir: &str) {
let path = PathBuf::from(dir);
if !path.exists() {
match std::fs::create_dir_all(&path) {
Ok(()) => println!("CleanupService: Created missing directory: {dir}"),
Err(e) => {
println!(
"CleanupService: Should monitor {dir} but it does not exist and could not be created: {e}"
);
return;
}
}
}
self.targets.lock().unwrap().push(path);
println!("CleanupService: Monitoring {dir}");
}
/// Lance la purge immédiatement puis toutes les 6 heures.
pub fn start(self: &std::sync::Arc<Self>) {
println!("CleanupService: Starting scheduler (Interval: 6h)");
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
loop {
interval.tick().await;
this.run_cleanup().await;
}
});
}
/// Purge manuelle (aussi exposée via /api/admin/purge).
pub async fn run_cleanup(&self) -> Value {
println!("CleanupService: Running cleanup task...");
let targets = self.targets.lock().unwrap().clone();
let max_age = self.max_age;
let (deleted, freed, errors) = tokio::task::spawn_blocking(move || {
let mut deleted = 0u64;
let mut freed = 0u64;
let mut errors = Vec::new();
let threshold = SystemTime::now() - max_age;
for dir in targets {
if !dir.exists() {
continue;
}
match clean_directory(&dir, threshold) {
Ok((count, bytes)) => {
deleted += count;
freed += bytes;
}
Err(e) => {
println!("CleanupService: Error processing {}: {e}", dir.display());
errors.push(format!("Error in {}: {e}", dir.display()));
}
}
}
(deleted, freed, errors)
})
.await
.unwrap_or((0, 0, vec!["cleanup task panicked".to_string()]));
println!(
"CleanupService: Cleanup complete. Deleted {deleted} files, freed {:.2} MB",
freed as f64 / 1024.0 / 1024.0
);
json!({
"status": "complete",
"deleted_files": deleted,
"freed_bytes": freed,
"timestamp": now_local_iso(),
"errors": errors,
})
}
}
/// Supprime récursivement les fichiers modifiés avant [threshold] (sans
/// suivre les liens symboliques).
fn clean_directory(dir: &Path, threshold: SystemTime) -> std::io::Result<(u64, u64)> {
let mut count = 0;
let mut bytes = 0;
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if let Ok((c, b)) = clean_directory(&entry.path(), threshold) {
count += c;
bytes += b;
}
continue;
}
if !file_type.is_file() {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
// Erreurs individuelles ignorées (fichier verrouillé…).
if modified < threshold && std::fs::remove_file(entry.path()).is_ok() {
count += 1;
bytes += meta.len();
}
}
Ok((count, bytes))
}
+367
View File
@@ -0,0 +1,367 @@
//! Registre des processus FFmpeg de transcodage (direct, film, lecture
//! d'enregistrement).
//!
//! Responsabilités :
//! - un processus par identifiant de session (`live_{id}_{qualité}`, …)
//! - le « reaper » tue les sessions inactives au-delà d'un délai (plus aucun
//! spectateur ne demande de segment)
//! - le dossier temporaire HLS est vidé au démarrage (orphelins d'un crash)
//! - l'arrêt du serveur tue tout (pas d'FFmpeg orphelin après `docker stop`)
use crate::util::log_redactor::redact_url;
use crate::util::process::ManagedChild;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tokio::io::{AsyncBufReadExt, BufReader};
const LIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(4 * 60);
const VOD_IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Playlist d'un direct non réécrite depuis ce délai : FFmpeg est bloqué.
const LIVE_STALL_TIMEOUT: Duration = Duration::from_secs(45);
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
fn now_ms() -> u64 {
EPOCH.elapsed().as_millis() as u64
}
/// Une session FFmpeg en cours.
pub struct FfmpegSession {
pub id: String,
pub child: ManagedChild,
pub dir: PathBuf,
pub is_live: bool,
pub started_at: Instant,
last_access_ms: AtomicU64,
/// Dernières lignes de stderr, pour le diagnostic d'un échec rapide.
recent_stderr: Mutex<VecDeque<String>>,
}
impl FfmpegSession {
pub fn touch(&self) {
self.last_access_ms.store(now_ms(), Ordering::Relaxed);
}
/// Temps écoulé depuis la dernière requête de playlist ou de segment.
pub fn idle_for(&self) -> Duration {
Duration::from_millis(now_ms().saturating_sub(self.last_access_ms.load(Ordering::Relaxed)))
}
pub fn exited(&self) -> bool {
self.child.has_exited()
}
pub fn exit_code(&self) -> Option<i32> {
self.child.exit_code()
}
pub fn playlist_path(&self) -> PathBuf {
self.dir.join("playlist.m3u8")
}
fn stderr_text(&self) -> String {
self.recent_stderr
.lock()
.unwrap()
.iter()
.cloned()
.collect::<String>()
.trim()
.to_string()
}
}
pub struct FfmpegSessionManager {
pub base_dir: PathBuf,
sessions: Mutex<HashMap<String, Arc<FfmpegSession>>>,
}
fn playlist_contains(path: &Path, needle: &str) -> bool {
std::fs::read_to_string(path).is_ok_and(|c| c.contains(needle))
}
impl FfmpegSessionManager {
pub fn new(base_dir: PathBuf) -> Self {
Self {
base_dir,
sessions: Mutex::new(HashMap::new()),
}
}
/// Vide les dossiers orphelins et démarre le reaper. Une fois au démarrage.
pub fn init(self: &Arc<Self>) {
if self.base_dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&self.base_dir)
{
println!("[FFmpegManager] Could not wipe temp dir: {e}");
}
let _ = std::fs::create_dir_all(&self.base_dir);
let this = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await;
loop {
interval.tick().await;
this.reap();
}
});
}
pub fn get(&self, id: &str) -> Option<Arc<FfmpegSession>> {
self.sessions.lock().unwrap().get(id).cloned()
}
/// Marque une session comme utilisée (routes de playlist ET de segment).
pub fn touch(&self, id: &str) {
if let Some(session) = self.get(id) {
session.touch();
}
}
pub fn contains(&self, id: &str) -> bool {
self.sessions.lock().unwrap().contains_key(id)
}
fn needs_start_for(existing: Option<&Arc<FfmpegSession>>, is_live: bool) -> bool {
let Some(existing) = existing else {
return true;
};
if !existing.exited() {
return false;
}
// Un transcodage VOD/enregistrement terminé proprement reste lisible
// depuis ses segments : on le réutilise au lieu de retranscoder.
is_live
|| existing.exit_code() != Some(0)
|| !playlist_contains(&existing.playlist_path(), "#EXT-X-ENDLIST")
}
/// Vrai si [get_or_start] lancerait un nouveau processus — donc une
/// nouvelle connexion au fournisseur.
pub fn needs_start(&self, id: &str, is_live: bool) -> bool {
let sessions = self.sessions.lock().unwrap();
Self::needs_start_for(sessions.get(id), is_live)
}
/// Session saine existante, ou nouveau processus FFmpeg. Le dossier de
/// session est recréé pour une nouvelle session ; [args] y est exécuté.
pub fn get_or_start(
&self,
id: &str,
is_live: bool,
ffmpeg_path: &str,
args: &[String],
) -> std::io::Result<Arc<FfmpegSession>> {
let mut sessions = self.sessions.lock().unwrap();
let existing = sessions.get(id).cloned();
if let Some(existing) = &existing {
if !Self::needs_start_for(Some(existing), is_live) {
existing.touch();
return Ok(existing.clone());
}
// Processus mort : nettoyer avant de relancer.
sessions.remove(id);
existing.child.terminate();
let _ = std::fs::remove_dir_all(&existing.dir);
}
let dir = self.base_dir.join(id);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
std::fs::create_dir_all(&dir)?;
let mut child = tokio::process::Command::new(ffmpeg_path)
.args(args)
.current_dir(&dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let stderr = child.stderr.take();
let managed = ManagedChild::spawn(child);
let session = Arc::new(FfmpegSession {
id: id.to_string(),
child: managed,
dir,
is_live,
started_at: Instant::now(),
last_access_ms: AtomicU64::new(now_ms()),
recent_stderr: Mutex::new(VecDeque::new()),
});
sessions.insert(id.to_string(), session.clone());
drop(sessions);
if let Some(stderr) = stderr {
let s = session.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut recent = s.recent_stderr.lock().unwrap();
recent.push_back(format!("{line}\n"));
if recent.len() > 20 {
recent.pop_front();
}
}
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from
// 'http://…' ») : masquer les identifiants Xtream.
println!("[FFmpeg {}] {}", s.id, redact_url(&line));
}
});
}
let s = session.clone();
tokio::spawn(async move {
let code = s.child.wait().await;
println!("[FFmpegManager] Session {} exited with code {code}", s.id);
});
Ok(session)
}
/// Attend que la playlist de la session référence au moins
/// [min_segments] segments. Échoue vite si le processus meurt avant d'avoir
/// produit quoi que ce soit, en renvoyant son stderr récent.
///
/// [min_segments] > 1 donne au lecteur une avance de démarrage. Une
/// playlist déjà close (`#EXT-X-ENDLIST`) est rendue telle quelle.
pub async fn wait_for_playlist(
&self,
session: &FfmpegSession,
min_segments: usize,
) -> (bool, Option<String>) {
let playlist = session.playlist_path();
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(content) = tokio::fs::read_to_string(&playlist).await {
let segments = content.matches(".ts").count();
if segments >= min_segments || (segments > 0 && content.contains("#EXT-X-ENDLIST"))
{
return (true, None);
}
}
if session.exited() {
// Un encodeur terminé proprement avec moins de segments que
// demandé a simplement fini : c'est un succès.
if session.exit_code() == Some(0) && playlist_contains(&playlist, ".ts") {
return (true, None);
}
return (
false,
Some(format!(
"FFmpeg exited ({}): {}",
session.exit_code().unwrap_or(-1),
session.stderr_text()
)),
);
}
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde
// de latence pure entre la disponibilité de la playlist et sa
// réponse.
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Délai écoulé : mieux vaut servir ce qui existe que rien.
if playlist_contains(&playlist, ".ts") {
return (true, None);
}
(false, Some("Timeout waiting for transcoder".to_string()))
}
pub fn kill_session(&self, id: &str) {
let Some(session) = self.sessions.lock().unwrap().remove(id) else {
return;
};
session.child.terminate();
if session.dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&session.dir)
{
println!("[FFmpegManager] Could not delete dir for {id}: {e}");
}
}
/// Tue les sessions sœurs d'un même contenu (`rec_<id>_t0`,
/// `rec_<id>_t600`…) que plus personne ne consomme depuis 30 s.
///
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
/// barre de progression laisserait autant de processus vivants jusqu'au
/// TTL de 15 minutes.
pub fn kill_idle_siblings(&self, prefix: &str, keep: &str) {
let stale: Vec<String> = self
.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.id != keep && s.id.starts_with(prefix))
.filter(|s| s.idle_for() >= Duration::from_secs(30))
.map(|s| s.id.clone())
.collect();
for id in stale {
println!("[FFmpegManager] Dropping stale sibling {id}");
self.kill_session(&id);
}
}
pub fn kill_all(&self) {
let ids: Vec<String> = self.sessions.lock().unwrap().keys().cloned().collect();
println!("[FFmpegManager] Killing {} session(s)", ids.len());
for id in ids {
self.kill_session(&id);
}
}
fn reap(&self) {
let sessions: Vec<Arc<FfmpegSession>> =
self.sessions.lock().unwrap().values().cloned().collect();
for session in sessions {
let timeout = if session.is_live {
LIVE_IDLE_TIMEOUT
} else {
VOD_IDLE_TIMEOUT
};
let idle = session.idle_for();
// Les sessions terminées gardent leurs segments jusqu'au délai
// d'inactivité : un film transcodé est souvent encore regardé.
if idle > timeout {
println!(
"[FFmpegManager] Reaping idle session {} (idle {}s)",
session.id,
idle.as_secs()
);
self.kill_session(&session.id);
continue;
}
// Chien de garde : un direct dont la playlist n'est plus réécrite
// est bloqué sur une source figée. Le tuer laisse la prochaine
// requête du lecteur relancer le transcodeur.
if session.is_live && !session.exited() && Self::is_stalled(&session) {
println!(
"[FFmpegManager] Restarting stalled live session {}",
session.id
);
self.kill_session(&session.id);
}
}
}
fn is_stalled(session: &FfmpegSession) -> bool {
match std::fs::metadata(session.playlist_path()).and_then(|m| m.modified()) {
Ok(modified) => SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age > LIVE_STALL_TIMEOUT),
// Jamais rien produit : délai compté depuis le démarrage.
Err(_) => session.started_at.elapsed() > LIVE_STALL_TIMEOUT,
}
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Repli GPU → CPU des transcodages.
//!
//! Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
//! sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
//! FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
//! repli, le lecteur recevait un 502, relançait… et retombait sur le même
//! GPU saturé : le flux restait illisible tant que la carte était pleine.
use regex::Regex;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
///
/// N'est consulté que pour une session lancée sur le GPU : « opening
/// encoder » y désigne donc forcément h264_nvenc.
static GPU_FAILURE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|out of memory|Device creation failed|hwaccel|opening encoder",
)
.unwrap()
});
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
/// réglerait pas, et qui doublerait inutilement la charge du serveur.
pub fn is_gpu_failure(error: Option<&str>) -> bool {
error.is_some_and(|e| GPU_FAILURE.is_match(e))
}
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
/// leur repli.
pub struct GpuHealth {
cooldown: Duration,
unavailable_until: Mutex<Option<Instant>>,
}
impl GpuHealth {
pub fn new(cooldown: Duration) -> Self {
Self {
cooldown,
unavailable_until: Mutex::new(None),
}
}
pub fn available(&self) -> bool {
self.available_at(Instant::now())
}
pub fn available_at(&self, now: Instant) -> bool {
match *self.unavailable_until.lock().unwrap() {
None => true,
Some(until) => now >= until,
}
}
pub fn mark_failed(&self) {
self.mark_failed_at(Instant::now());
}
pub fn mark_failed_at(&self, now: Instant) {
*self.unavailable_until.lock().unwrap() = Some(now + self.cooldown);
}
}
impl Default for GpuHealth {
fn default() -> Self {
Self::new(Duration::from_secs(120))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detecte_les_pannes_gpu() {
assert!(is_gpu_failure(Some(
"FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: out of memory (10)"
)));
assert!(is_gpu_failure(Some(
"[h264_nvenc @ 0x1] No capable devices found"
)));
assert!(is_gpu_failure(Some(
"cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY"
)));
assert!(is_gpu_failure(Some(
"Error while opening encoder for output stream #0:0 - maybe incorrect parameters such as bit_rate, rate, width or height"
)));
}
#[test]
fn ignore_les_pannes_amont_et_les_delais() {
assert!(!is_gpu_failure(Some(
"FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 Not Found"
)));
assert!(!is_gpu_failure(Some("Timeout waiting for transcoder")));
assert!(!is_gpu_failure(None));
}
#[test]
fn disponible_apres_le_delai() {
let health = GpuHealth::new(Duration::from_secs(120));
let t0 = Instant::now();
assert!(health.available_at(t0));
health.mark_failed_at(t0);
assert!(!health.available_at(t0 + Duration::from_secs(119)));
assert!(health.available_at(t0 + Duration::from_secs(120)));
}
}
+645
View File
@@ -0,0 +1,645 @@
//! Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
//!
//! POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
//! revendeur. Quand cette machine tombe — cas observé : serveur de picons en
//! maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
//! Le dépôt `tv-logos` est maintenu, nommé de façon régulière
//! (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::future::{BoxFuture, FutureExt, Shared};
use regex::Regex;
use reqwest::header::{ACCEPT, USER_AGENT};
use crate::util::log_redactor::redact_url;
/// GitHub refuse les requêtes sans `User-Agent`.
const USER_AGENT_VALUE: &str = "XtremFlow";
/// Plafond d'une requête (liste ou image).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
type SharedListing = Shared<BoxFuture<'static, Option<Arc<Listing>>>>;
/// Catalogue de logos, partagé via `Arc<LogoCatalog>`.
pub struct LogoCatalog {
inner: Arc<Inner>,
}
struct Inner {
client: reqwest::Client,
api_base: String,
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
listing_ttl: Duration,
/// Recul après un échec de téléchargement de liste (limite de débit,
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
retry_backoff: Duration,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
listings: HashMap<&'static str, Arc<Listing>>,
listings_in_flight: HashMap<&'static str, SharedListing>,
listing_failed_at: HashMap<&'static str, Instant>,
/// URL de téléchargement → octets PNG.
bytes: HashMap<String, Bytes>,
}
struct Listing {
/// Clé normalisée → URL de téléchargement brute.
urls: HashMap<String, String>,
fetched_at: Instant,
}
impl Inner {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Libère la place de la requête de liste en cours quand elle se termine,
/// même en cas de panique.
struct InFlightGuard(Arc<Inner>, &'static str);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0.lock().listings_in_flight.remove(self.1);
}
}
impl LogoCatalog {
pub fn new(client: reqwest::Client) -> Self {
Self::with_api_base(client, "https://api.github.com".to_string())
}
/// `api_base` remplace `https://api.github.com` (tests).
pub fn with_api_base(client: reqwest::Client, api_base: String) -> Self {
Self {
inner: Arc::new(Inner {
client,
api_base: api_base.trim_end_matches('/').to_string(),
listing_ttl: Duration::from_secs(24 * 3600),
retry_backoff: Duration::from_secs(15 * 60),
state: Mutex::new(State::default()),
}),
}
}
/// Octets PNG du logo correspondant à `channel_name`, ou `None`.
pub async fn logo_for(&self, channel_name: &str) -> Option<Bytes> {
let country = country_folder_for(channel_name);
let listing = self.listing_for(country).await?;
let key = match_logo_key(channel_name, listing.urls.keys())?;
let url = listing.urls.get(&key)?.clone();
if let Some(cached) = self.inner.lock().bytes.get(&url) {
return Some(cached.clone());
}
let download = async {
let response = self
.inner
.client
.get(&url)
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
response.bytes().await.ok()
};
let bytes = tokio::time::timeout(REQUEST_TIMEOUT, download)
.await
.ok()
.flatten()?;
self.inner.lock().bytes.insert(url, bytes.clone());
Some(bytes)
}
async fn listing_for(&self, country: &'static str) -> Option<Arc<Listing>> {
let pending = {
let mut state = self.inner.lock();
let cached = state.listings.get(country).cloned();
if let Some(listing) = &cached
&& listing.fetched_at.elapsed() < self.inner.listing_ttl
{
return cached;
}
if state
.listing_failed_at
.get(country)
.is_some_and(|at| at.elapsed() < self.inner.retry_backoff)
{
// Liste périmée plutôt que rien : les logos ne changent pas
// d'un jour à l'autre.
return cached;
}
// Les tuiles d'une grille arrivent en rafale : une seule requête
// de liste. Elle tourne dans sa propre tâche pour aller au bout
// même si le premier demandeur abandonne.
match state.listings_in_flight.get(country) {
Some(pending) => pending.clone(),
None => {
let inner = Arc::clone(&self.inner);
let task = tokio::spawn(async move {
let _guard = InFlightGuard(Arc::clone(&inner), country);
fetch_listing(&inner, country).await
});
let pending = async move { task.await.ok().flatten() }.boxed().shared();
state.listings_in_flight.insert(country, pending.clone());
pending
}
}
};
pending.await
}
}
async fn fetch_listing(inner: &Inner, country: &'static str) -> Option<Arc<Listing>> {
let url = format!(
"{}/repos/tv-logo/tv-logos/contents/countries/{country}",
inner.api_base
);
match request_listing(inner, &url).await {
Ok(urls) => {
let count = urls.len();
let listing = Arc::new(Listing {
urls,
fetched_at: Instant::now(),
});
{
let mut state = inner.lock();
state.listings.insert(country, Arc::clone(&listing));
state.listing_failed_at.remove(country);
}
println!("[LogoCatalog] {country} : {count} logos");
Some(listing)
}
Err(error) => {
let stale = {
let mut state = inner.lock();
state.listing_failed_at.insert(country, Instant::now());
state.listings.get(country).cloned()
};
println!(
"[LogoCatalog] liste {country} indisponible : {}",
redact_url(&error)
);
stale
}
}
}
/// Liste des PNG d'un dossier pays : clé normalisée → URL brute.
async fn request_listing(inner: &Inner, url: &str) -> Result<HashMap<String, String>, String> {
let request = async {
let response = inner
.client
.get(url)
.header(ACCEPT, "application/vnd.github+json")
.header(USER_AGENT, USER_AGENT_VALUE)
.send()
.await
.map_err(|e| error_text(&e))?;
let status = response.status().as_u16();
if status != 200 {
return Err(format!("Bad state: HTTP {status}"));
}
response.bytes().await.map_err(|e| error_text(&e))
};
let body = tokio::time::timeout(REQUEST_TIMEOUT, request)
.await
.map_err(|_| "TimeoutException after 0:00:20.000000: Future not completed".to_string())??;
let decoded: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&body))
.map_err(|e| format!("FormatException: {e}"))?;
let mut urls = HashMap::new();
if let serde_json::Value::Array(entries) = decoded {
for entry in entries {
let serde_json::Value::Object(entry) = entry else {
continue;
};
let name = entry
.get("name")
.and_then(dart_to_string)
.unwrap_or_default();
let Some(download) = entry.get("download_url").and_then(dart_to_string) else {
continue;
};
if !name.ends_with(".png") {
continue;
}
urls.insert(key_from_file_name(&name), download);
}
}
Ok(urls)
}
/// `value?.toString()` de Dart : `None` pour `null`.
fn dart_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// Texte d'une erreur avec ses causes.
fn error_text(error: &dyn std::error::Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
fn key_from_file_name(name: &str) -> String {
let base = &name[..name.len() - ".png".len()];
let bytes = base.as_bytes();
let n = bytes.len();
if n >= 3
&& bytes[n - 3] == b'-'
&& bytes[n - 2].is_ascii_lowercase()
&& bytes[n - 1].is_ascii_lowercase()
{
base[..n - 3].to_string()
} else {
base.to_string()
}
}
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
fn country_folder(code: &str) -> Option<&'static str> {
Some(match code {
"fr" => "france",
"be" => "belgium",
"ch" => "switzerland",
"uk" | "gb" => "united-kingdom",
"us" => "united-states",
"ca" => "canada",
"de" => "germany",
"es" => "spain",
"it" => "italy",
"pt" => "portugal",
"nl" => "netherlands",
_ => return None,
})
}
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
///
/// `\W` et `\s` reproduits avec leur sens Dart (ASCII pour `\W`, espaces
/// JavaScript pour `\s`), les classes de `regex` étant Unicode.
static COUNTRY_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^[^A-Za-z0-9_]*([A-Za-z]{2})[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}\-:|]+",
)
.unwrap()
});
/// Dossier `tv-logos` à consulter pour `channel_name`. France par défaut :
/// c'est l'audience de l'application.
pub fn country_folder_for(channel_name: &str) -> &'static str {
COUNTRY_PREFIX
.captures(channel_name)
.and_then(|caps| country_folder(&caps[1].to_lowercase()))
.unwrap_or("france")
}
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
/// de flux. Ils sont propres au panneau et absents des noms de logos.
const NOISE: &[&str] = &[
"fhd", "uhd", "hd", "sd", "4k", "8k", "hdr", "hevc", "h264", "h265", "1080p", "720p", "50fps",
"fps", "raw", "backup", "vip", "multi", "vf", "vo", "vostfr",
];
fn fold_accent(c: char) -> char {
match c {
'à' | 'á' | 'â' | 'ä' => 'a',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ò' | 'ó' | 'ô' | 'ö' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
'ç' => 'c',
other => other,
}
}
fn tokens(channel_name: &str) -> Vec<String> {
let mut name = channel_name;
if let Some(caps) = COUNTRY_PREFIX.captures(name)
&& country_folder(&caps[1].to_lowercase()).is_some()
{
name = &name[caps.get(0).map_or(0, |m| m.end())..];
}
let folded: String = name.to_lowercase().chars().map(fold_accent).collect();
folded
.replace('+', " plus ")
.replace('&', " and ")
.split(|c: char| !matches!(c, 'a'..='z' | '0'..='9'))
.filter(|t| !t.is_empty() && !NOISE.contains(t))
.map(str::to_string)
.collect()
}
/// Clé de logo la plus proche de `channel_name` parmi `available`, ou `None`.
///
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
/// France 24.
pub fn match_logo_key<'a>(
channel_name: &str,
available: impl IntoIterator<Item = &'a String>,
) -> Option<String> {
let keys: HashSet<&str> = available.into_iter().map(String::as_str).collect();
let tokens = tokens(channel_name);
if tokens.is_empty() {
return None;
}
let without_live = |parts: &[String]| -> Vec<String> {
parts.iter().filter(|t| *t != "live").cloned().collect()
};
let exact = |parts: &[String]| -> Option<String> {
let slug = parts.join("-");
(!parts.is_empty() && keys.contains(slug.as_str())).then_some(slug)
};
let longer_variant = |parts: &[String]| -> Option<String> {
if parts.len() < 2 {
return None;
}
let prefix = format!("{}-", parts.join("-"));
// Plus courte d'abord (longueur en unités UTF-16 comme en Dart), puis
// ordre alphabétique.
keys.iter()
.filter(|k| k.starts_with(&prefix))
.min_by(|a, b| {
a.encode_utf16()
.count()
.cmp(&b.encode_utf16().count())
.then_with(|| a.cmp(b))
})
.map(|k| k.to_string())
};
let no_live = without_live(&tokens);
let direct = exact(&tokens)
.or_else(|| exact(&no_live))
.or_else(|| longer_variant(&tokens))
.or_else(|| longer_variant(&no_live));
if direct.is_some() {
return direct;
}
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
let last = tokens.last()?;
if !last.is_empty() && last.bytes().all(|b| b.is_ascii_digit()) {
let brand = without_live(&tokens[..tokens.len() - 1]);
if brand.len() >= 2 {
return exact(&brand).or_else(|| longer_variant(&brand));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::http::{StatusCode, Uri};
use std::sync::atomic::{AtomicUsize, Ordering};
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans
/// `-fr.png`).
const FRANCE: &[&str] = &[
"bein-sports-1-french",
"bein-sports-2-french",
"bein-sports",
"canal-plus",
"canal-plus-sport",
"canal-plus-sport-360",
"eurosport-1",
"france-2",
"france-24",
"lequipe",
"rmc-sport-1",
"rmc-sport-2",
"rmc-sport-access-1",
];
fn matched(name: &str) -> Option<String> {
let keys: Vec<String> = FRANCE.iter().map(|s| s.to_string()).collect();
match_logo_key(name, &keys)
}
#[test]
fn correspondance_exacte_apres_nettoyage_du_nom_panneau() {
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
assert_eq!(
matched("FR - RMC SPORT 2 FHD").as_deref(),
Some("rmc-sport-2")
);
assert_eq!(matched("FR: France 2 HD").as_deref(), Some("france-2"));
assert_eq!(
matched("|FR| EUROSPORT 1 UHD").as_deref(),
Some("eurosport-1")
);
}
#[test]
fn traduit_le_plus_de_canal_plus() {
assert_eq!(
matched("FR - CANAL+ SPORT FHD").as_deref(),
Some("canal-plus-sport")
);
assert_eq!(matched("FR - CANAL+").as_deref(), Some("canal-plus"));
}
#[test]
fn accepte_une_variante_plus_longue_du_meme_identifiant() {
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
assert_eq!(
matched("FR - BEIN SPORTS 1 FHD").as_deref(),
Some("bein-sports-1-french")
);
}
#[test]
fn retombe_sur_la_marque_quand_le_numero_de_canal_est_inconnu() {
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
// celui de la marque vaut mieux qu'une tuile vide.
assert_eq!(
matched("FR - RMC SPORT LIVE 5 FHD").as_deref(),
Some("rmc-sport-1")
);
assert_eq!(
matched("FR - CANAL+ SPORT 2").as_deref(),
Some("canal-plus-sport")
);
}
#[test]
fn ne_devine_pas_sur_un_seul_mot() {
// « France » seul ne doit pas donner le logo de France 24.
assert_eq!(matched("FR - FRANCE"), None);
}
#[test]
fn rend_none_quand_rien_ne_correspond() {
assert_eq!(matched("FR - DAZN FHD"), None);
assert_eq!(matched(""), None);
}
#[test]
fn country_folder_lit_le_prefixe_pays_du_nom() {
assert_eq!(country_folder_for("FR - TF1"), "france");
assert_eq!(country_folder_for("BE: RTL TVI"), "belgium");
assert_eq!(country_folder_for("|UK| BBC ONE"), "united-kingdom");
}
#[test]
fn country_folder_france_par_defaut_sans_prefixe_reconnu() {
assert_eq!(country_folder_for("TF1 HD"), "france");
}
#[test]
fn cle_depuis_le_nom_de_fichier() {
assert_eq!(key_from_file_name("rmc-sport-2-fr.png"), "rmc-sport-2");
assert_eq!(key_from_file_name("lequipe.png"), "lequipe");
assert_eq!(key_from_file_name("bbc-one-uk.png"), "bbc-one");
}
/// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les
/// requêtes et répond selon le chemin. `{base}` dans le corps est
/// remplacé par l'adresse du serveur.
async fn serve(
respond: impl Fn(&Uri) -> (StatusCode, String) + Send + Sync + 'static,
) -> (String, Arc<Mutex<Vec<String>>>) {
let requested = Arc::new(Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let respond = Arc::new(respond);
let log = Arc::clone(&requested);
let base_for_body = base.clone();
let app = Router::new().fallback(move |uri: Uri| {
let respond = Arc::clone(&respond);
let log = Arc::clone(&log);
let base = base_for_body.clone();
async move {
log.lock().unwrap().push(uri.to_string());
let (status, body) = respond(&uri);
(status, body.replace("{base}", &base))
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, requested)
}
#[tokio::test]
async fn telecharge_le_logo_apparie_et_le_garde_en_cache() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(
StatusCode::OK,
serde_json::json!([
{"name": "rmc-sport-2-fr.png", "download_url": "{base}/raw/rmc-sport-2-fr.png"},
{"name": "README.md", "download_url": "{base}/r"},
])
.to_string(),
)
} else {
(StatusCode::OK, "\u{1}\u{2}\u{3}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
let first = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
let second = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
assert_eq!(first.as_deref(), Some(&[1u8, 2, 3][..]));
assert_eq!(second.as_deref(), Some(&[1u8, 2, 3][..]));
// Une liste et un logo, pas un aller-retour par affichage.
let requested = requested.lock().unwrap().clone();
assert_eq!(requested.len(), 2, "{requested:?}");
assert_eq!(
requested[0],
"/repos/tv-logo/tv-logos/contents/countries/france"
);
}
#[tokio::test]
async fn rend_none_sans_correspondance_sans_telecharger_d_image() {
let downloads = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&downloads);
let (base, _) = serve(move |uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
counter.fetch_add(1, Ordering::SeqCst);
(StatusCode::OK, "\u{1}".to_string())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
assert_eq!(catalog.logo_for("FR - CHAINE INCONNUE").await, None);
assert_eq!(downloads.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn espace_les_tentatives_quand_la_liste_est_injoignable() {
let (base, requested) =
serve(|_| (StatusCode::FORBIDDEN, "rate limited".to_string())).await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
catalog.logo_for("FR - RMC SPORT 2").await;
catalog.logo_for("FR - RMC SPORT 1").await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn une_seule_requete_de_liste_pour_une_rafale_de_tuiles() {
let (base, requested) = serve(|uri| {
if uri.path().starts_with("/repos/") {
(StatusCode::OK, "[]".to_string())
} else {
(StatusCode::OK, String::new())
}
})
.await;
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
futures::future::join_all((0..8).map(|i| {
let name = format!("FR - CHAINE {i}");
let catalog = &catalog;
async move { catalog.logo_for(&name).await }
}))
.await;
assert_eq!(requested.lock().unwrap().len(), 1);
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod cleanup;
pub mod ffmpeg_sessions;
pub mod gpu_fallback;
pub mod logo_catalog;
pub mod recording_decision;
pub mod recording_scheduler;
pub mod upstream_slots;
pub mod xmltv;
+241
View File
@@ -0,0 +1,241 @@
//! Logique de décision pure du planificateur d'enregistrements, isolée pour
//! être testable.
use chrono::{DateTime, Duration, Utc};
#[derive(Debug, PartialEq, Eq)]
pub enum RecordingAction {
/// Démarrer maintenant.
Start,
/// Capacité atteinte mais fenêtre encore ouverte : réessayer plus tard.
Wait,
/// L'heure de fin est passée avant que la capture ait pu démarrer.
Fail,
/// Pas encore l'heure.
None,
}
/// Que faire d'un enregistrement planifié à ce tick du scheduler ?
pub fn decide_recording_action(
now: DateTime<Utc>,
start: DateTime<Utc>,
end: DateTime<Utc>,
active_count: usize,
max_concurrent: usize,
) -> RecordingAction {
if now > end {
return RecordingAction::Fail;
}
if now <= start {
return RecordingAction::None;
}
if active_count >= max_concurrent {
return RecordingAction::Wait;
}
RecordingAction::Start
}
/// Sort d'un enregistrement resté au statut « recording » en base alors
/// qu'aucun processus FFmpeg ne lui correspond (redémarrage du conteneur…).
#[derive(Debug, PartialEq, Eq)]
pub enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
Resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
Finish,
/// Rien d'exploitable n'a été capturé.
Fail,
}
pub fn decide_orphan_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
has_file: bool,
) -> OrphanAction {
let remaining = end - now;
if remaining > Duration::seconds(60) {
return OrphanAction::Resume;
}
if has_file {
OrphanAction::Finish
} else {
OrphanAction::Fail
}
}
/// Suite à donner quand un processus FFmpeg se termine.
#[derive(Debug, PartialEq, Eq)]
pub enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : terminé.
Complete,
/// FFmpeg s'est arrêté trop tôt : relancer sur la fin de fenêtre.
Retry,
/// Arrêt prématuré sans rien d'exploitable.
Fail,
}
/// Échecs FFmpeg consécutifs tolérés avant d'abandonner un enregistrement
/// dont la fenêtre est encore ouverte. Le compteur est remis à zéro dès
/// qu'un lancement a duré assez longtemps pour être considéré sain.
pub const MAX_FFMPEG_ATTEMPTS: u32 = 30;
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
pub fn decide_post_exit_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
exit_code: i32,
consecutive_failures: u32,
has_file: bool,
) -> PostExitAction {
let remaining = end - now;
if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS {
return PostExitAction::Retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
let clean_exit = exit_code == 0 || exit_code == 255;
if clean_exit || has_file {
PostExitAction::Complete
} else {
PostExitAction::Fail
}
}
/// Durée de capture (`-t`) : le temps restant jusqu'à la fin programmée,
/// et non la durée théorique du programme — un démarrage tardif (ou une
/// relance après coupure) ne doit pas décaler la fin de l'enregistrement.
pub fn capture_duration(now: DateTime<Utc>, end: DateTime<Utc>) -> Duration {
let remaining = end - now;
let minimum = Duration::seconds(30);
if remaining < minimum {
minimum
} else {
remaining
}
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
pub fn ffmpeg_retry_delay(consecutive_failures: u32) -> std::time::Duration {
let steps = (consecutive_failures as i64 - 1).clamp(0, 4) as u32;
let seconds = 3u64 * (1 << steps);
std::time::Duration::from_secs(seconds.min(30))
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> DateTime<Utc> {
"2026-08-27T21:00:00Z".parse().unwrap()
}
#[test]
fn actions_de_planification() {
let n = now();
let m = Duration::minutes;
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 0, 2),
RecordingAction::Start
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 2, 2),
RecordingAction::Wait
);
assert_eq!(
decide_recording_action(n, n - m(120), n - m(5), 0, 2),
RecordingAction::Fail
);
assert_eq!(
decide_recording_action(n, n + m(30), n + m(60), 0, 2),
RecordingAction::None
);
assert_eq!(
decide_recording_action(n, n - m(1), n + m(60), 1, 2),
RecordingAction::Start
);
}
#[test]
fn actions_orphelines() {
let n = now();
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), true),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n + Duration::minutes(30), false),
OrphanAction::Resume
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), true),
OrphanAction::Finish
);
assert_eq!(
decide_orphan_action(n, n - Duration::minutes(5), false),
OrphanAction::Fail
);
assert_eq!(
decide_orphan_action(n, n + Duration::seconds(20), true),
OrphanAction::Finish
);
}
#[test]
fn actions_apres_sortie() {
let n = now();
let m40 = n + Duration::minutes(40);
assert_eq!(
decide_post_exit_action(n, m40, 1, 0, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 0, 2, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 0, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, false),
PostExitAction::Fail
);
}
#[test]
fn duree_de_capture() {
let n = now();
assert_eq!(
capture_duration(n, n + Duration::minutes(58)),
Duration::minutes(58)
);
assert_eq!(
capture_duration(n, n - Duration::minutes(5)),
Duration::seconds(30)
);
}
#[test]
fn delai_de_relance() {
let s = std::time::Duration::from_secs;
assert_eq!(ffmpeg_retry_delay(1), s(3));
assert_eq!(ffmpeg_retry_delay(2), s(6));
assert_eq!(ffmpeg_retry_delay(3), s(12));
assert_eq!(ffmpeg_retry_delay(4), s(24));
assert_eq!(ffmpeg_retry_delay(5), s(30));
assert_eq!(ffmpeg_retry_delay(50), s(30));
assert_eq!(ffmpeg_retry_delay(0), s(3));
}
}
File diff suppressed because it is too large. Load diff
+429
View File
@@ -0,0 +1,429 @@
//! Connexions ouvertes vers le fournisseur Xtream, par compte.
//!
//! POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
//! simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
//! lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
//! ou la fin du téléchargement pour un film, le temps de détecter la
//! déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
//! le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
//! films « indisponibles » et les zaps qui échouent.
//!
//! Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
//! connexions ORPHELINES du même compte (plus personne ne les consomme),
//! les plus anciennes d'abord, jusqu'à lui faire de la place.
//!
//! Un flux encore regardé n'est jamais coupé : deux lecteurs ouverts sur un
//! compte à une connexion se coupaient sinon en boucle (constaté en prod).
//! Le second lecteur est refusé par le panneau et le premier continue.
//!
//! Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
use crate::models::PlaylistConfig;
use futures::future::BoxFuture;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
type Release = Box<dyn Fn() + Send + Sync>;
type IsActive = Box<dyn Fn() -> bool + Send + Sync>;
struct Slot {
token: u64,
account: String,
/// Ordre d'inscription : les plus anciennes connexions partent d'abord.
since: u64,
release: Release,
is_active: Option<IsActive>,
}
impl Slot {
fn active(&self) -> bool {
self.is_active.as_ref().is_some_and(|f| f())
}
}
#[derive(Default)]
struct Inner {
slots: HashMap<String, Slot>,
next: u64,
}
#[derive(Default)]
pub struct UpstreamSlots {
inner: Mutex<Inner>,
}
impl UpstreamSlots {
pub fn new() -> Self {
Self::default()
}
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
///
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
/// le même identifiant ne doit pas être désinscrite par la fin de
/// l'ancien processus. [is_active] : vrai tant qu'un spectateur consomme
/// le flux ; une connexion active n'est jamais coupée.
pub fn register(
&self,
id: &str,
account: &str,
release: Release,
is_active: Option<IsActive>,
) -> u64 {
let mut inner = self.inner.lock().unwrap();
inner.next += 1;
let token = inner.next;
inner.slots.insert(
id.to_string(),
Slot {
token,
account: account.to_string(),
since: token,
release,
is_active,
},
);
token
}
pub fn unregister(&self, id: &str, token: Option<u64>) {
let mut inner = self.inner.lock().unwrap();
let Some(slot) = inner.slots.get(id) else {
return;
};
if token.is_some_and(|t| t != slot.token) {
return;
}
inner.slots.remove(id);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn count_for(&self, account: &str) -> usize {
self.inner
.lock()
.unwrap()
.slots
.values()
.filter(|s| s.account == account)
.count()
}
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
///
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
/// d'interrompre un autre spectateur.
pub fn make_room(&self, account: &str, max: i64, keep: &str) -> Vec<String> {
if max <= 0 {
return Vec::new();
}
let released: Vec<(String, Release)> = {
let mut inner = self.inner.lock().unwrap();
let others: Vec<&String> = inner
.slots
.iter()
.filter(|(id, s)| s.account == account && id.as_str() != keep)
.map(|(id, _)| id)
.collect();
let excess = others.len() as i64 - (max - 1);
if excess <= 0 {
return Vec::new();
}
let mut orphans: Vec<(String, u64)> = others
.into_iter()
.filter(|id| !inner.slots[*id].active())
.map(|id| (id.clone(), inner.slots[id].since))
.collect();
orphans.sort_by_key(|(_, since)| *since);
orphans
.into_iter()
.take(excess as usize)
.filter_map(|(id, _)| inner.slots.remove(&id).map(|s| (id, s.release)))
.collect()
};
// Coupures hors du verrou : un `release` peut toucher d'autres registres.
released
.into_iter()
.map(|(id, release)| {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(&release));
id
})
.collect()
}
}
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
/// même quota chez le fournisseur.
pub fn account_key_of(p: &PlaylistConfig) -> String {
format!("{}|{}", p.dns, p.username)
}
/// `user_info.max_connections` d'une réponse `player_api.php`, ou `None`.
pub fn parse_max_connections(json: &serde_json::Value) -> Option<i64> {
let raw = json.get("user_info")?.as_object()?.get("max_connections")?;
let value = match raw {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}?;
(value > 0).then_some(value)
}
pub type FetchLimit = Arc<dyn Fn(PlaylistConfig) -> BoxFuture<'static, Option<i64>> + Send + Sync>;
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
///
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
/// répondre (mesuré en prod). La valeur connue (même périmée), sinon
/// [FALLBACK], est rendue immédiatement ; la lecture chez le panneau se fait
/// en arrière-plan. Sans risque : le quota ne sert qu'à décider quels flux
/// ORPHELINS couper.
pub struct AccountLimits {
ttl: Duration,
fetch: FetchLimit,
cache: Arc<Mutex<HashMap<String, (i64, Instant)>>>,
refreshing: Arc<Mutex<HashSet<String>>>,
}
impl AccountLimits {
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
/// cas de loin le plus courant chez les fournisseurs Xtream.
pub const FALLBACK: i64 = 1;
pub fn new(ttl: Duration, fetch: FetchLimit) -> Self {
Self {
ttl,
fetch,
cache: Arc::default(),
refreshing: Arc::default(),
}
}
/// Lecture réelle chez le panneau.
pub fn from_panel(client: reqwest::Client) -> Self {
let fetch: FetchLimit = Arc::new(move |p: PlaylistConfig| {
let client = client.clone();
Box::pin(async move {
let mut url = reqwest::Url::parse(&format!("{}/player_api.php", p.dns)).ok()?;
url.query_pairs_mut()
.append_pair("username", &p.username)
.append_pair("password", &p.password);
let send = client
.get(url)
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(5), send)
.await
.ok()?
.ok()?;
if response.status().as_u16() != 200 {
return None;
}
let body = tokio::time::timeout(Duration::from_secs(5), response.bytes())
.await
.ok()?
.ok()?;
let json: serde_json::Value = serde_json::from_slice(&body).ok()?;
parse_max_connections(&json)
})
});
Self::new(Duration::from_secs(600), fetch)
}
pub fn max_for(&self, p: &PlaylistConfig) -> i64 {
let key = account_key_of(p);
let cached = self.cache.lock().unwrap().get(&key).copied();
if cached.is_none_or(|(_, at)| at.elapsed() >= self.ttl) {
self.refresh(key, p.clone());
}
cached.map(|(max, _)| max).unwrap_or(Self::FALLBACK)
}
fn refresh(&self, key: String, p: PlaylistConfig) {
if !self.refreshing.lock().unwrap().insert(key.clone()) {
return; // une seule lecture à la fois
}
let fetch = self.fetch.clone();
let cache = self.cache.clone();
let refreshing = self.refreshing.clone();
tokio::spawn(async move {
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
if let Some(value) = fetch(p).await {
cache
.lock()
.unwrap()
.insert(key.clone(), (value, Instant::now()));
}
refreshing.lock().unwrap().remove(&key);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn collector() -> (Arc<Mutex<Vec<String>>>, impl Fn(&str) -> Release) {
let released = Arc::new(Mutex::new(Vec::new()));
let r = released.clone();
let make = move |id: &str| -> Release {
let r = r.clone();
let id = id.to_string();
Box::new(move || r.lock().unwrap().push(id.clone()))
};
(released, make)
}
#[test]
fn compte_a_une_connexion_le_flux_precedent_est_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert_eq!(
slots.make_room("acc", 1, "live_2_source"),
vec!["live_1_source"]
);
assert_eq!(*released.lock().unwrap(), vec!["live_1_source"]);
assert_eq!(slots.count_for("acc"), 0);
}
#[test]
fn la_session_demandee_n_est_jamais_coupee() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("live_1_source", "acc", make("live_1_source"), None);
assert!(slots.make_room("acc", 1, "live_1_source").is_empty());
assert!(released.lock().unwrap().is_empty());
}
#[test]
fn les_plus_anciennes_partent_d_abord() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
for id in ["a", "b", "c"] {
slots.register(id, "acc", make(id), None);
}
assert_eq!(slots.make_room("acc", 3, "new"), vec!["a"]);
assert_eq!(slots.count_for("acc"), 2);
}
#[test]
fn un_autre_compte_n_est_jamais_touche() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("x", "other", make("x"), None);
assert!(slots.make_room("acc", 1, "new").is_empty());
assert_eq!(slots.count_for("other"), 1);
}
#[test]
fn jeton_perime_ne_libere_pas_la_session_relancee() {
let slots = UpstreamSlots::new();
let old = slots.register("s", "acc", Box::new(|| {}), None);
slots.register("s", "acc", Box::new(|| {}), None);
slots.unregister("s", Some(old));
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn un_flux_regarde_n_est_jamais_coupe() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
assert!(slots.make_room("acc", 1, "new").is_empty());
assert!(released.lock().unwrap().is_empty());
assert_eq!(slots.count_for("acc"), 1);
}
#[test]
fn seuls_les_orphelins_partent() {
let slots = UpstreamSlots::new();
let (released, make) = collector();
slots.register("watched", "acc", make("watched"), Some(Box::new(|| true)));
slots.register("orphan", "acc", make("orphan"), None);
assert_eq!(slots.make_room("acc", 1, "new"), vec!["orphan"]);
assert_eq!(*released.lock().unwrap(), vec!["orphan"]);
}
#[test]
fn quota_inconnu_rien_n_est_coupe() {
let slots = UpstreamSlots::new();
let (_released, make) = collector();
slots.register("a", "acc", make("a"), None);
assert!(slots.make_room("acc", 0, "new").is_empty());
}
fn playlist() -> PlaylistConfig {
PlaylistConfig {
dns: "http://panel.test".into(),
username: "u".into(),
password: "x".into(),
}
}
#[tokio::test]
async fn ne_fait_jamais_attendre_un_flux() {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<i64>>();
let rx = Arc::new(Mutex::new(Some(rx)));
let fetch: FetchLimit = Arc::new(move |_| {
let rx = rx.lock().unwrap().take();
Box::pin(async move {
match rx {
Some(rx) => rx.await.ok().flatten(),
None => None,
}
})
});
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tx.send(Some(3)).unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 3);
}
#[tokio::test]
async fn valeur_perimee_servie_pendant_le_rafraichissement() {
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let fetch: FetchLimit = Arc::new(move |_| {
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
Box::pin(async move { Some(if n == 1 { 2 } else { 5 }) })
});
let limits = AccountLimits::new(Duration::ZERO, fetch);
limits.max_for(&playlist());
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 2);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), 5);
}
#[tokio::test]
async fn panneau_en_echec_repli_sans_exception() {
let fetch: FetchLimit = Arc::new(|_| Box::pin(async { None }));
let limits = AccountLimits::new(Duration::from_secs(600), fetch);
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(limits.max_for(&playlist()), AccountLimits::FALLBACK);
}
#[test]
fn lit_max_connections() {
use serde_json::json;
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "1"}})),
Some(1)
);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": 3}})),
Some(3)
);
assert_eq!(parse_max_connections(&json!({"user_info": {}})), None);
assert_eq!(parse_max_connections(&json!("oops")), None);
assert_eq!(
parse_max_connections(&json!({"user_info": {"max_connections": "0"}})),
None
);
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more