diff --git a/.dockerignore b/.dockerignore index 9c34788..9f07ade 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,8 +6,7 @@ .packages .pub-cache build/ -bin/.dart_tool -bin/.packages +server/target ios/ android/ windows/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06708d7..5a4887c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 05cfa78..0920b06 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/BUILD.md b/BUILD.md index 2f763a1..125bf32 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdc610..cdf537c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index d408095..d666be6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.fast b/Dockerfile.fast index 86d9705..0543d36 100644 --- a/Dockerfile.fast +++ b/Dockerfile.fast @@ -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"] diff --git a/README.md b/README.md index 8fba73c..c407b70 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/analysis_options.yaml b/analysis_options.yaml index c5ddfce..f97b24f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 diff --git a/bin/api/asset_failure_cache.dart b/bin/api/asset_failure_cache.dart deleted file mode 100644 index 94f4793..0000000 --- a/bin/api/asset_failure_cache.dart +++ /dev/null @@ -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 _failures = {}; - final Map _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); - } -} diff --git a/bin/api/auth_handler.dart b/bin/api/auth_handler.dart deleted file mode 100644 index 38855b5..0000000 --- a/bin/api/auth_handler.dart +++ /dev/null @@ -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 _login(Request request) async { - try { - print('[Auth] Login attempt received'); - final bodyStr = await request.readAsString(); - - final payload = jsonDecode(bodyStr) as Map; - 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 _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 _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; - } -} diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart deleted file mode 100644 index cf3e031..0000000 --- a/bin/api/epg_api.dart +++ /dev/null @@ -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/?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 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 _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 _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 _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 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>> Function())>[ - ('panel-xmltv', () => _panelProgrammes(playlist, channelId)), - ('xmltv', () => _xmltvProgrammes(playlist, channelId)), - ('xtream', () => _xtreamProgrammes(playlist, channelId)), - ]; - - var epgData = { - 'channel_id': channelId, - 'programmes': >[], - }; - var source = 'xtream'; - var found = false; - - for (final (name, fetch) in sources) { - final programmes = await fetch(); - if (programmes.isEmpty) continue; - - final candidate = { - '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>> _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>> _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 parsed; - try { - parsed = transformEpgData(json.decode(response.body), channelId); - } catch (_) { - continue; - } - final programmes = (parsed['programmes'] as List) - .whereType>() - .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 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>> _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>> _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, Map) _parseChannelTable( - String body, - ) { - final epgIds = {}; - final names = {}; - 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> _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 = {}; - var names = {}; - 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 transformEpgData(dynamic raw, String channelId) { - try { - List listings = []; - - if (raw is Map && raw.containsKey('epg_listings')) { - listings = raw['epg_listings'] as List? ?? []; - } 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 epgIds; - final Map 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}); -} diff --git a/bin/api/logo_api.dart b/bin/api/logo_api.dart deleted file mode 100644 index 4742008..0000000 --- a/bin/api/logo_api.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:shelf/shelf.dart'; - -import '../services/logo_catalog.dart'; -import 'asset_failure_cache.dart'; - -/// GET /api/logo?src=&name= -/// -/// 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/`. - final Handler _proxy; - - final LogoCatalog _catalog; - - /// Un logo trouvé change rarement : un jour de cache navigateur. - static const _found = 'public, max-age=86400'; - - Future 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(); - } - - 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(); - } -} diff --git a/bin/api/player_log.dart b/bin/api/player_log.dart deleted file mode 100644 index 3a17c2b..0000000 --- a/bin/api/player_log.dart +++ /dev/null @@ -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 = [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+'), '') - .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 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); -} diff --git a/bin/api/playlists_handler.dart b/bin/api/playlists_handler.dart deleted file mode 100644 index 7ccd9cf..0000000 --- a/bin/api/playlists_handler.dart +++ /dev/null @@ -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('/', _updatePlaylist); - router.delete('/', _deletePlaylist); - - return router; - } - - /// GET /api/playlists - Future _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 _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; - 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 _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; - 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 _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'}, - ); - } - } -} diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart deleted file mode 100644 index 644cd87..0000000 --- a/bin/api/proxy_handler.dart +++ /dev/null @@ -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 Function(Request) _getPlaylist; - final AppDatabase _db; - final http.Client _client = http.Client(); - - final Map _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 _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 = { - '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? 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().catchError((_) {})); - return missingImageResponse(); - } - _assetFailures.recordSuccess(targetUrl.host); - } - - // Build response headers from source response - final responseHeaders = { - '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'}, - ); - } - }; - } -} diff --git a/bin/api/recording_playlist.dart b/bin/api/recording_playlist.dart deleted file mode 100644 index ac2b178..0000000 --- a/bin/api/recording_playlist.dart +++ /dev/null @@ -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=`), 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]}', - ); -} diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart deleted file mode 100644 index cd0475b..0000000 --- a/bin/api/recordings_api.dart +++ /dev/null @@ -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 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/ - /// Exposé séparément car shelf_router a un conflit entre DELETE / et GET /logs/ - Future 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 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 = >[]; - 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 _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 _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 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 data; - try { - data = json.decode(await request.readAsString()) as Map; - } 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/ — 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 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/ — Arrête un enregistrement FFmpeg en cours - Future 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é'}); - } -} diff --git a/bin/api/season_passes_api.dart b/bin/api/season_passes_api.dart deleted file mode 100644 index 9d96f56..0000000 --- a/bin/api/season_passes_api.dart +++ /dev/null @@ -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 handlePost(Request request) async { - try { - final body = await request.readAsString(); - final data = json.decode(body) as Map; - - 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/ — 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'}, - ); - } - } -} diff --git a/bin/api/settings_handler.dart b/bin/api/settings_handler.dart deleted file mode 100644 index f3876ea..0000000 --- a/bin/api/settings_handler.dart +++ /dev/null @@ -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; - } -} diff --git a/bin/api/simple_recording_api.dart b/bin/api/simple_recording_api.dart deleted file mode 100644 index 9f90bcf..0000000 --- a/bin/api/simple_recording_api.dart +++ /dev/null @@ -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/', _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 _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 _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/ - /// Stop recording immediately - Future _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'}, - ); - } -} diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart deleted file mode 100644 index 20b5536..0000000 --- a/bin/api/streaming_handler.dart +++ /dev/null @@ -1,1015 +0,0 @@ -import 'dart:io'; -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; -import 'package:http/http.dart' as http; -import '../database/database.dart'; -import '../models/playlist_config.dart'; -import '../services/ffmpeg_session_manager.dart'; -import '../services/gpu_fallback.dart'; -import '../services/upstream_slots.dart'; -import '../utils/log_redactor.dart'; -import '../utils/media_probe.dart'; -import '../utils/stream_pipe.dart'; -import 'recording_playlist.dart'; - -/// Directory for temporary HLS segments -final Directory _hlsTempDir = - Directory('${Directory.systemTemp.path}/xtremflow_streams'); - -/// Global FFmpeg session registry (initialized in [initStreaming]). -final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir); - -/// Pannes GPU récentes, partagées par toutes les routes de transcodage. -final GpuHealth _gpuHealth = GpuHealth(); - -/// Connexions de lecture ouvertes chez le fournisseur, par compte. -final UpstreamSlots _upstreamSlots = UpstreamSlots(); - -/// Numérote les flux `turbo.ts` (un processus FFmpeg par requête). -int _turboCounter = 0; - -/// Quota `max_connections` de chaque compte Xtream. -final AccountLimits _accountLimits = AccountLimits(); - -/// Fait de la place chez le fournisseur avant d'ouvrir la connexion [id]. -/// -/// Voir [UpstreamSlots] : sur un compte à une connexion, le flux précédent -/// (souvent un FFmpeg orphelin que plus personne ne regarde) bloquait le -/// suivant. -Future _claimUpstream(PlaylistConfig playlist, String id) async { - final max = await _accountLimits.maxFor(playlist); - final freed = - _upstreamSlots.makeRoom(accountKeyOf(playlist), max: max, keep: id); - if (freed.isEmpty) return; - print('[Upstream] $id : ${freed.join(', ')} coupé(s) ' - '(quota fournisseur : $max connexion(s))'); - // Laisser le panneau constater la fermeture : rouvrir aussitôt se fait - // refuser sur les comptes à une seule connexion. - await Future.delayed(const Duration(seconds: 1)); -} - -/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin. -/// -/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux -/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau -/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes. -const _viewerGrace = Duration(seconds: 15); - -/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit. -/// -/// [lastActivity] : dernier signe de vie du spectateur ; la connexion n'est -/// coupée pour en ouvrir une autre que s'il remonte à plus de -/// [_viewerGrace]. -void _registerUpstream( - PlaylistConfig playlist, - String id, - Process process, { - required void Function() release, - required DateTime Function() lastActivity, -}) { - final token = _upstreamSlots.register( - id: id, - account: accountKeyOf(playlist), - release: release, - isActive: () => - DateTime.now().difference(lastActivity()) < _viewerGrace, - ); - process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token)); -} - -/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable) -String _getFFmpegPath() { - if (Platform.isWindows) { - if (File('ffmpeg.exe').existsSync()) return 'ffmpeg.exe'; - if (File('bin/ffmpeg.exe').existsSync()) return 'bin/ffmpeg.exe'; - if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) { - return 'ffmpeg/bin/ffmpeg.exe'; - } - } - return 'ffmpeg'; // Default to PATH -} - -/// Check if NVIDIA GPU acceleration is enabled via environment variable -bool _isNvidiaGpuEnabled() { - final envValue = Platform.environment['NVIDIA_GPU'] ?? - Platform.environment['nvidia_gpu'] ?? - 'false'; - return envValue.toLowerCase() == 'true' || envValue == '1'; -} - -/// Initialize streaming subsystem -Future initStreaming() async { - await sessionManager.init(); -} - -/// Supported quality presets. `source` skips video transcoding entirely -/// (`-c:v copy`) — a huge CPU win since most Xtream streams are already -/// H.264. Audio is always normalized to AAC for HLS compatibility. -const supportedQualities = {'source', 'high', 'medium', 'low'}; - -String _sanitizeQuality(String? quality) { - return supportedQualities.contains(quality) ? quality! : 'high'; -} - -bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id); - -/// Extension de conteneur d'un film/épisode, validée (elle finit dans l'URL -/// du panneau). `mkv` par défaut : c'était l'unique valeur jusqu'ici, les -/// anciens clients qui n'envoient pas `ext` gardent leur comportement. -String vodContainerExtension(String? ext) { - final value = ext?.toLowerCase(); - return value != null && RegExp(r'^[a-z0-9]{2,5}$').hasMatch(value) - ? value - : 'mkv'; -} - -/// Video encoding args for the requested quality (live profile). -List _liveVideoArgs(String quality, bool gpu) { - switch (quality) { - case 'source': - return ['-c:v', 'copy']; - case 'medium': - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', - '-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k', - '-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p', - '-g', '50', - ] - : [ - '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', - '-profile:v', 'high', '-level', '4.0', - '-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k', - '-pix_fmt', 'yuv420p', '-g', '50', - ]; - case 'low': - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', - '-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k', - '-vf', 'scale=-2:720', - '-pix_fmt', 'yuv420p', '-g', '50', - ] - : [ - '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', - '-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k', - '-vf', 'scale=-2:720', - '-pix_fmt', 'yuv420p', '-g', '50', - ]; - case 'high': - default: - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', - '-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', - '-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p', - '-g', '50', - ] - : [ - // veryfast au lieu de medium : en live, l'encodeur doit tenir - // le temps réel ET produire le premier segment vite ; medium - // ajoutait plusieurs secondes au zap sans gain visible à ce - // débit. - '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', - '-profile:v', 'high', '-level', '4.0', - '-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k', - '-pix_fmt', 'yuv420p', '-g', '50', - ]; - } -} - -/// Video encoding args for the requested quality (VOD profile). -List _vodVideoArgs(String quality, bool gpu) { - switch (quality) { - case 'source': - return ['-c:v', 'copy']; - case 'medium': - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', - '-rc', 'cbr', '-b:v', '2500k', '-maxrate', '3000k', - '-bufsize', '5000k', '-g', '48', '-bf', '2', - '-pix_fmt', 'yuv420p', - ] - : [ - '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', - '-maxrate', '6000k', '-bufsize', '12000k', - '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0', - ]; - case 'low': - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', - '-rc', 'cbr', '-b:v', '1500k', '-maxrate', '2000k', - '-bufsize', '3000k', '-vf', 'scale=-2:720', - '-g', '48', '-pix_fmt', 'yuv420p', - ] - : [ - '-c:v', 'libx264', '-preset', 'fast', '-crf', '26', - '-maxrate', '3000k', '-bufsize', '6000k', - '-vf', 'scale=-2:720', - '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0', - ]; - case 'high': - default: - return gpu - ? [ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', - '-rc', 'cbr', '-b:v', '4000k', '-maxrate', '4500k', - '-bufsize', '8000k', '-g', '48', '-bf', '2', - '-pix_fmt', 'yuv420p', - ] - : [ - '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', - '-maxrate', '12000k', '-bufsize', '24000k', - '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0', - ]; - } -} - -/// Audio args. Source mode keeps it simple (plain AAC); transcoded modes -/// keep the downmix + loudness normalization filter. -List _audioArgs({required bool withFilters}) { - return [ - '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000', - if (withFilters) - ...['-af', - 'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15'] - else - ...['-af', 'aresample=async=1'], - ]; -} - -Map _hlsHeaders() => { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Cache-Control': 'no-cache', - 'X-Content-Type-Options': 'nosniff', - }; - -Map _segmentHeaders({int maxAge = 60}) => { - 'Content-Type': 'video/mp2t', - 'Cache-Control': 'max-age=$maxAge', - }; - -typedef _SessionAttempt = ({FfmpegSession? session, bool ready, String? error}); - -/// Démarre (ou récupère) la session [id] et attend sa playlist. -/// -/// Un `Process.start` qui échoue (serveur à court de processus ou de -/// mémoire) devient un échec ordinaire au lieu d'une exception non -/// rattrapée qui remontait en 500. -/// -/// [upstream] : playlist dont la session ouvre une connexion chez le -/// fournisseur (direct, film). Null pour un enregistrement, lu sur disque. -Future<_SessionAttempt> _runSession({ - required String id, - required bool isLive, - required List Function(Directory dir) argsBuilder, - int minSegments = 1, - PlaylistConfig? upstream, -}) async { - final starting = sessionManager.needsStart(id, isLive: isLive); - if (upstream != null && starting) await _claimUpstream(upstream, id); - - final FfmpegSession session; - try { - session = await sessionManager.getOrStart( - id: id, - isLive: isLive, - ffmpegPath: _getFFmpegPath(), - argsBuilder: argsBuilder, - ); - } on ProcessException catch (e) { - return (session: null, ready: false, error: 'FFmpeg start failed: $e'); - } - if (upstream != null && starting) { - _registerUpstream( - upstream, - id, - session.process, - release: () => sessionManager.killSession(id), - // Touchée à chaque requête de playlist ou de segment. - lastActivity: () => session.lastAccess, - ); - } - final outcome = - await sessionManager.waitForPlaylist(session, minSegments: minSegments); - return (session: session, ready: outcome.ready, error: outcome.error); -} - -/// Comme [_runSession], avec repli sur le CPU si l'encodage GPU échoue. -/// -/// Le repli garde le même identifiant de session : les requêtes de segments -/// qui suivent tombent sur la session CPU sans que le lecteur s'en aperçoive. -/// Une panne GPU fait aussi partir les sessions suivantes directement sur le -/// CPU pendant le délai de [GpuHealth], au lieu d'échouer une à une. -Future<_SessionAttempt> _runWithGpuFallback({ - required String id, - required bool isLive, - required bool wantGpu, - required List Function(bool gpu) buildArgs, - int minSegments = 1, - PlaylistConfig? upstream, -}) async { - final gpu = wantGpu && _gpuHealth.available; - var attempt = await _runSession( - id: id, - isLive: isLive, - argsBuilder: (_) => buildArgs(gpu), - minSegments: minSegments, - upstream: upstream, - ); - - if (!attempt.ready && gpu && isGpuFailure(attempt.error)) { - _gpuHealth.markFailed(); - print('[GPU] $id : encodage GPU refusé ' - '(${LogRedactor.redactUrl(attempt.error ?? '')}) — repli sur le CPU'); - sessionManager.killSession(id); - attempt = await _runSession( - id: id, - isLive: isLive, - argsBuilder: (_) => buildArgs(false), - minSegments: minSegments, - upstream: upstream, - ); - } - return attempt; -} - -// ========================================== -// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy) -// ========================================== - -/// Connexion amont ouverte, avec son client pour pouvoir le refermer. -class _LiveUpstream { - final http.Client client; - final http.StreamedResponse response; - _LiveUpstream(this.client, this.response); -} - -Future<_LiveUpstream?> _openLiveUpstream(String targetUrl) async { - final client = http.Client(); - try { - final request = http.Request('GET', Uri.parse(targetUrl)) - ..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18' - ..headers['Accept'] = '*/*'; - final response = await client.send(request); - return _LiveUpstream(client, response); - } catch (_) { - client.close(); - return null; - } -} - -/// Nombre de reconnexions consécutives tentées avant d'abandonner le flux. -const _liveProxyMaxRetries = 4; - -/// Corps du proxy `.ts` direct, avec reconnexion amont. -/// -/// Les panneaux Xtream ferment régulièrement la connexion en cours de route -/// (bascule de source, limite de connexions simultanées, recyclage nginx). -/// Sans reprise, le `MediaSource` du navigateur reçoit un `sourceEnded` et la -/// lecture s'arrête net — le symptôme « ça coupe au bout de 30 s ». -/// On rouvre donc la source tant que le client, lui, écoute toujours. -/// -/// Le compteur de tentatives est remis à zéro dès qu'une connexion a duré -/// assez longtemps pour être considérée saine : une coupure toutes les -/// 10 minutes ne doit pas finir par épuiser le quota. -Stream> _resilientLiveBody( - String streamId, - String targetUrl, - _LiveUpstream first, -) async* { - var upstream = first; - var attempt = 0; - - while (true) { - final startedAt = DateTime.now(); - var bytes = 0; - - try { - await for (final chunk in upstream.response.stream) { - bytes += chunk.length; - yield chunk; - } - } catch (e) { - // Un `ClientException` recopie l'URL amont, identifiants compris. - print( - '[Live Proxy] $streamId : coupure amont ' - '(${LogRedactor.redactUrl('$e')})', - ); - } finally { - upstream.client.close(); - } - - final lasted = DateTime.now().difference(startedAt); - if (lasted > const Duration(seconds: 60) && bytes > 0) { - attempt = 0; - } - - print( - '[Live Proxy] $streamId : flux terminé après ${lasted.inSeconds}s ' - '($bytes octets)', - ); - - // Rouvrir la source, en réessayant tant qu'il reste du quota. - _LiveUpstream? next; - while (next == null && attempt < _liveProxyMaxRetries) { - attempt++; - // Laisser le panneau libérer le slot de connexion précédent : sur les - // comptes limités à une connexion simultanée, rouvrir immédiatement se - // fait refuser. - await Future.delayed(const Duration(seconds: 1)); - - final candidate = await _openLiveUpstream(targetUrl); - if (candidate != null && candidate.response.statusCode == 200) { - next = candidate; - } else { - candidate?.client.close(); - print( - '[Live Proxy] $streamId : reconnexion ' - '$attempt/$_liveProxyMaxRetries échouée', - ); - } - } - - if (next == null) { - print('[Live Proxy] $streamId : abandon, source injoignable'); - return; - } - upstream = next; - } -} - -Handler createLiveStreamHandler( - Future Function(Request) getPlaylist, { - bool Function()? isGpuEnabled, -}) { - final router = Router(); - - Future servePlaylist( - Request request, - String streamId, - String quality, - ) async { - if (!_isValidStreamId(streamId)) { - return Response.badRequest(body: 'Invalid stream ID'); - } - final playlist = await getPlaylist(request); - if (playlist == null) return Response.forbidden('No playlist'); - - final targetUrl = - '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - final sessionId = 'live_${streamId}_$quality'; - - if (!sessionManager.contains(sessionId)) { - print( - '[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}', - ); - } - - final result = await _runWithGpuFallback( - id: sessionId, - isLive: true, - upstream: playlist, - wantGpu: useNvidiaGpu && quality != 'source', - buildArgs: (gpu) => [ - '-hide_banner', '-loglevel', 'warning', - if (gpu) ...['-hwaccel', 'cuda'], - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - '-reconnect', '1', '-reconnect_streamed', '1', - '-reconnect_at_eof', '1', - '-reconnect_delay_max', '10', - // Abort reads stuck for 30s so a stalled upstream triggers the - // reconnect logic instead of wedging the process forever. - '-rw_timeout', '30000000', - // Démarrage rapide : sans borne, FFmpeg peut passer plusieurs - // secondes à sonder le flux avant d'écrire le premier segment. - '-fflags', 'nobuffer', - '-probesize', '1000000', - '-analyzeduration', '1000000', - '-i', targetUrl, - ..._liveVideoArgs(quality, gpu), - ..._audioArgs(withFilters: false), - // HLS sliding window: 10 x 2s segments (lower live latency than the - // previous 20-segment window while still safe for iOS). - // hls_init_time 1: first segment closes after ~1s so playback can - // start sooner; subsequent segments use hls_time. - '-f', 'hls', - '-hls_init_time', '1', - '-hls_time', '2', - '-hls_list_size', '10', - '-hls_flags', 'delete_segments+independent_segments', - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', 'seg_%03d.ts', - 'playlist.m3u8', - ], - ); - - final session = result.session; - if (!result.ready || session == null) { - sessionManager.killSession(sessionId); - return Response(502, body: 'Live transcoder failed: ${result.error}'); - } - - session.touch(); - return Response.ok( - File('${session.dir.path}/playlist.m3u8').openRead(), - headers: _hlsHeaders(), - ); - } - - // Route: /api/live/{streamId}/{quality}/playlist.m3u8 - router.get('///playlist.m3u8', - (Request request, String streamId, String quality) { - return servePlaylist(request, streamId, _sanitizeQuality(quality)); - }); - - // Back-compat route: /api/live/{streamId}/playlist.m3u8 (?quality=...) - // Redirects so relative segment URLs resolve inside the quality path. - router.get('//playlist.m3u8', - (Request request, String streamId) async { - final quality = - _sanitizeQuality(request.url.queryParameters['quality']); - return Response.found('/api/live/$streamId/$quality/playlist.m3u8'); - }); - - // Route: /api/live/{streamId}/turbo.ts — flux MPEG-TS continu pour le - // player web (mpegts.js) : vidéo copiée telle quelle, audio TOUJOURS - // réencodé en AAC. - // - // POURQUOI : mpegts.js ne démuxe que l'AAC et le MP3. Les chaînes qui - // diffusent en AC-3/E-AC-3 (0x81/0x87) ou en MPEG-1 Layer II passaient par - // le proxy brut → image sans aucun son. Le réencodage audio seul coûte - // quelques % de CPU (pas de transcodage vidéo) et garde le zapping - // instantané : un seul flux continu, pas de segmentation HLS à amorcer. - // - // Latence : -fflags nobuffer + probesize réduit → première image en - // ~0,5-1,5 s au lieu des 2-6 s d'analyse par défaut de FFmpeg. - router.get('//turbo.ts', (Request request, String streamId) async { - if (!_isValidStreamId(streamId)) { - return Response.badRequest(body: 'Invalid stream ID'); - } - final playlist = await getPlaylist(request); - if (playlist == null) return Response.forbidden('No playlist'); - - final targetUrl = - '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - print( - '[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}', - ); - - // Un identifiant par requête : deux onglets sur la même chaîne sont - // deux connexions distinctes chez le fournisseur. - final upstreamId = 'turbo_${streamId}_${++_turboCounter}'; - await _claimUpstream(playlist, upstreamId); - - final Process process; - try { - process = await Process.start(_getFFmpegPath(), [ - '-hide_banner', '-loglevel', 'warning', - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - '-reconnect', '1', '-reconnect_streamed', '1', - '-reconnect_at_eof', '1', - '-reconnect_delay_max', '10', - '-rw_timeout', '30000000', - // Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite. - '-fflags', 'nobuffer', - '-flags', 'low_delay', - '-probesize', '1000000', - '-analyzeduration', '1000000', - '-i', targetUrl, - '-c:v', 'copy', - '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', - '-af', 'aresample=async=1', - // Pas de délai de mux : les paquets partent dès qu'ils existent. - '-muxdelay', '0', '-muxpreload', '0', - '-f', 'mpegts', 'pipe:1', - ]); - } on ProcessException catch (e) { - // Serveur saturé (plus de processus ou de mémoire) : un 503 que le - // lecteur sait traiter, plutôt qu'une exception qui remonte en 500. - // `ProcessException` liste les arguments, donc l'URL `-i` du panneau. - print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré ' - '(${LogRedactor.redactUrl('$e')})'); - return Response(503, body: 'FFmpeg start failed'); - } - - // Dernier octet remis au client : un lecteur en vie en reçoit à chaque - // rafale ; un client parti (zap mal détecté derrière le proxy) n'en - // reçoit plus, la contre-pression bloquant FFmpeg. - var lastDelivered = DateTime.now(); - _registerUpstream( - playlist, - upstreamId, - process, - release: () => process.kill(ProcessSignal.sigterm), - lastActivity: () => lastDelivered, - ); - - // Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux. - process.stderr.transform(const SystemEncoding().decoder).listen((line) { - final trimmed = line.trim(); - if (trimmed.isNotEmpty) { - print('[Live Turbo] $streamId ffmpeg: ' - '${LogRedactor.redactUrl(trimmed)}'); - } - }); - - // Relayer stdout vers le client en gardant la contre-pression, et tuer - // FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus - // s'accumulent à chaque zap). - final body = pipeWithBackpressure( - process.stdout, - onStop: () => process.kill(ProcessSignal.sigterm), - ).map((chunk) { - lastDelivered = DateTime.now(); - return chunk; - }); - - return Response( - 200, - body: body, - headers: { - 'Content-Type': 'video/mp2t', - 'Cache-Control': 'no-store', - 'Connection': 'keep-alive', - }, - ); - }); - - // Route: /api/live/{streamId}.ts (Direct Proxy for recordings/raw playback) - router.get('/.ts', (Request request, String streamId) async { - final playlist = await getPlaylist(request); - if (playlist == null) return Response.forbidden('No playlist'); - - final targetUrl = - '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - print( - '[Live Proxy] Forwarding $streamId: ${LogRedactor.redactUrl(targetUrl)}'); - - // Première connexion faite hors du générateur : elle seule décide du code - // de réponse renvoyé au client. - final first = await _openLiveUpstream(targetUrl); - if (first == null) { - return Response(502, body: 'Upstream connection failed'); - } - if (first.response.statusCode != 200) { - first.client.close(); - return Response(first.response.statusCode, body: 'Upstream refused'); - } - - return Response( - 200, - body: _resilientLiveBody(streamId, targetUrl, first), - headers: { - 'Content-Type': 'video/mp2t', - 'Connection': 'keep-alive', - }, - ); - }); - - // Route: /api/live/{streamId}/{quality}/{segment} - router.get('///', - (Request request, String streamId, String quality, String segment) { - if (!_isValidStreamId(streamId) || segment.contains('..')) { - return Response.badRequest(body: 'Invalid request'); - } - final sessionId = 'live_${streamId}_${_sanitizeQuality(quality)}'; - sessionManager.touch(sessionId); - final file = File('${_hlsTempDir.path}/$sessionId/$segment'); - if (!file.existsSync()) return Response.notFound('Segment not found'); - - return Response.ok(file.openRead(), headers: _segmentHeaders()); - }); - - return router.call; -} - -// ========================================== -// 2. VOD HANDLER (FFmpeg HLS Transcoding) -// ========================================== - -Handler createVodStreamHandler( - Future Function(Request) getPlaylist, { - bool Function()? isGpuEnabled, -}) { - final router = Router(); - - Future servePlaylist( - Request request, - String streamId, - String quality, - ) async { - if (!_isValidStreamId(streamId)) { - return Response.badRequest(body: 'Invalid stream ID'); - } - final playlist = await getPlaylist(request); - if (playlist == null) { - return Response.internalServerError(body: 'No playlist'); - } - - // ?type=series or ?type=movie selects the upstream path - final contentType = request.url.queryParameters['type'] ?? 'movie'; - final basePath = contentType == 'series' ? 'series' : 'movie'; - - // Extension réelle du fichier (`container_extension` du catalogue) : le - // panneau refuse un `.mkv` imposé à un film stocké en `.mp4`. - final targetUrl = - '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/' - '$streamId.${vodContainerExtension(request.url.queryParameters['ext'])}'; - final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - final sessionId = 'vod_${streamId}_$quality'; - - if (!sessionManager.contains(sessionId)) { - print( - '[VOD] Starting $sessionId ($contentType): ${LogRedactor.redactUrl(targetUrl)}', - ); - } - - final result = await _runWithGpuFallback( - id: sessionId, - isLive: false, - upstream: playlist, - wantGpu: useNvidiaGpu && quality != 'source', - buildArgs: (gpu) => [ - '-hide_banner', '-loglevel', 'warning', - if (gpu) ...['-hwaccel', 'cuda'], - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '10', - '-rw_timeout', '30000000', - '-timeout', '30000000', - // Sonde réduite (10 Mo → 5 Mo) : sur un upstream lent, télécharger - // 10 Mo avant la première image ajoutait plusieurs secondes au - // démarrage de chaque film. - '-analyzeduration', '2000000', - '-probesize', '5000000', - '-i', targetUrl, - ..._vodVideoArgs(quality, gpu), - ..._audioArgs(withFilters: quality != 'source'), - '-f', 'hls', - '-hls_time', '4', - '-hls_list_size', '0', - '-hls_playlist_type', 'event', - '-hls_allow_cache', '1', - '-hls_flags', 'independent_segments', - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', 'segment_%03d.ts', - '-start_number', '0', - 'playlist.m3u8', - ], - ); - - final session = result.session; - if (!result.ready || session == null) { - sessionManager.killSession(sessionId); - return Response(502, body: 'VOD transcoder failed: ${result.error}'); - } - - session.touch(); - return Response.ok( - File('${session.dir.path}/playlist.m3u8').openRead(), - headers: _hlsHeaders(), - ); - } - - // Route: /api/vod/{streamId}/{quality}/playlist.m3u8 - router.get('///playlist.m3u8', - (Request request, String streamId, String quality) { - return servePlaylist(request, streamId, _sanitizeQuality(quality)); - }); - - // Back-compat route: /api/vod/{streamId}/playlist.m3u8 (?quality=...) - router.get('//playlist.m3u8', - (Request request, String streamId) async { - final quality = - _sanitizeQuality(request.url.queryParameters['quality']); - final params = request.url.queryParameters; - final query = Uri(queryParameters: { - if (params['type'] != null) 'type': params['type']!, - if (params['ext'] != null) 'ext': params['ext']!, - }).query; - return Response.found( - '/api/vod/$streamId/$quality/playlist.m3u8' - '${query.isEmpty ? '' : '?$query'}', - ); - }); - - // Route: /api/vod/{streamId}/{quality}/{segment} - router.get('///', - (Request request, String streamId, String quality, String segment) { - if (!_isValidStreamId(streamId) || segment.contains('..')) { - return Response.badRequest(body: 'Invalid request'); - } - final sessionId = 'vod_${streamId}_${_sanitizeQuality(quality)}'; - sessionManager.touch(sessionId); - final file = File('${_hlsTempDir.path}/$sessionId/$segment'); - if (!file.existsSync()) return Response.notFound('Segment not found'); - - return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600)); - }); - - return router.call; -} - -// ========================================== -// 3. RECORDING HANDLER (FFmpeg HLS Transcoding) -// ========================================== - -Handler createRecordingStreamHandler( - AppDatabase db, { - bool Function()? isGpuEnabled, -}) { - final router = Router(); - - router.get('//playlist.m3u8', - (Request request, String streamId) async { - if (!_isValidStreamId(streamId)) { - return Response.badRequest(body: 'Invalid request'); - } - - final recording = db.getRecordingById(streamId); - if (recording == null) { - return Response.notFound('Recording not found'); - } - - // Check recording status - only allow streaming if completed or recording - if (recording.status == 'scheduled') { - return Response(202, body: 'Recording not yet started'); - } - if (recording.status == 'failed') { - return Response(422, - body: 'Recording failed: ${recording.errorReason ?? 'unknown error'}'); - } - if (recording.status != 'completed' && recording.status != 'recording') { - return Response.internalServerError( - body: 'Invalid recording status: ${recording.status}'); - } - - if (recording.filePath == null) { - return Response.internalServerError(body: 'Recording file path not set'); - } - - final targetUrl = recording.filePath!; - if (!File(targetUrl).existsSync()) { - return Response.internalServerError( - body: 'Physical recording file not found'); - } - - final start = parseRecordingStart(request.url.queryParameters['start']); - final offsetKey = recordingOffsetKey(start); - final sessionPrefix = 'rec_${streamId}_t'; - final sessionId = 'rec_${streamId}_$offsetKey'; - - // La capture est faite en `-c copy` : le fichier contient le codec - // d'origine de la chaîne, presque toujours du H.264 — directement - // lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p, - // si bien que la lecture démarrait collée au front d'encodage et se - // coupait à la moindre hésitation. En copie, la segmentation va à la - // vitesse du disque : l'enregistrement devient navigable en quelques - // secondes. Seuls les codecs que le navigateur ne sait pas lire - // (HEVC, MPEG-2…) justifient encore un ré-encodage. - final videoCodec = await MediaProbe.videoCodec(targetUrl); - final canCopyVideo = videoCodec == 'h264'; - - List buildArgs({required bool copyVideo, required bool gpu}) { - final useNvidiaGpu = !copyVideo && gpu; - return [ - '-hide_banner', '-loglevel', 'warning', - if (useNvidiaGpu) ...['-hwaccel', 'cuda'], - // -ss AVANT -i : recherche rapide par index, sans décoder ce qui - // précède. La sortie repart de 0, le lecteur ré-ajoute l'offset. - // En copie, la position atteinte est l'image-clé qui précède. - if (start > 0) ...['-ss', '$start'], - '-i', targetUrl, - if (copyVideo) ...[ - '-c:v', 'copy', - ] else if (useNvidiaGpu) ...[ - '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', - '-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k', - '-bufsize', '6000k', - '-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p', - ] else ...[ - // veryfast/crf 20 au lieu de medium/crf 18 : à ce niveau le rendu - // est visuellement identique, mais medium ne tenait pas le temps - // réel en 1080p sur un CPU modeste → lecture d'enregistrement qui - // démarre lentement puis bufferise. - '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', - '-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p', - '-g', '48', '-threads', '0', - ], - ..._audioArgs(withFilters: true), - // `event` et non `vod` : en VOD, hls.js considère la playlist comme - // définitive et ne la relit jamais. Il ne voyait donc que les - // quelques secondes déjà transcodées à l'ouverture — d'où une durée - // absurde et une barre de progression inutilisable. En `event`, la - // playlist est rechargée et la zone navigable grandit avec l'encodage. - '-f', 'hls', '-hls_time', '4', '-hls_list_size', '0', - '-hls_playlist_type', 'event', '-hls_allow_cache', '1', - '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', - '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', - 'playlist.m3u8', - ]; - } - - /// Démarre (ou récupère) la session et attend qu'elle ait de l'avance. - /// - /// Trois segments avant de rendre la main : c'est la marge qui manquait - /// au démarrage, la playlist étant servie dès le premier segment. En - /// copie vidéo elle est produite en une fraction de seconde, elle ne - /// coûte donc que sur un ré-encodage. - Future<_SessionAttempt> run(bool copyVideo) { - if (!sessionManager.contains(sessionId)) { - print( - '[Recording] $sessionId : ' - '${copyVideo ? 'copie vidéo' : 'ré-encodage'} ' - '(codec source : ${videoCodec ?? 'inconnu'})', - ); - } - if (copyVideo) { - return _runSession( - id: sessionId, - isLive: false, - argsBuilder: (_) => buildArgs(copyVideo: true, gpu: false), - minSegments: 3, - ); - } - return _runWithGpuFallback( - id: sessionId, - isLive: false, - wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(), - buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu), - minSegments: 3, - ); - } - - var attempt = await run(canCopyVideo); - - // La copie a échoué : certains conteneurs ou horodatages ne s'y prêtent - // pas. Plutôt qu'un écran d'erreur, on retombe sur le ré-encodage, qui - // était le seul chemin jusqu'ici. - if (!attempt.ready && canCopyVideo) { - print( - '[Recording] $sessionId : copie vidéo refusée (${attempt.error}) ' - '— repli sur le ré-encodage', - ); - sessionManager.killSession(sessionId); - attempt = await run(false); - } - - final session = attempt.session; - if (!attempt.ready || session == null) { - sessionManager.killSession(sessionId); - return Response(502, - body: 'Recording transcoder failed: ${attempt.error}'); - } - - session.touch(); - // Ménage des offsets abandonnés (aller-retours dans la barre de - // progression), une fois la nouvelle session confirmée démarrée. - sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId); - - // Les segments sont préfixés par l'offset pour rester rattachés à LEUR - // session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à - // 45 min sont deux fichiers différents. - final playlist = await File('${session.dir.path}/playlist.m3u8') - .readAsString(); - - return Response.ok( - rewriteRecordingPlaylist(playlist, offsetKey), - headers: _hlsHeaders(), - ); - }); - - Response serveSegment(String streamId, String offsetKey, String segment) { - if (!_isValidStreamId(streamId) || - parseRecordingOffsetKey(offsetKey) == null || - segment.contains('..') || - segment.contains('/')) { - return Response.badRequest(body: 'Invalid request'); - } - final sessionId = 'rec_${streamId}_$offsetKey'; - sessionManager.touch(sessionId); - final file = File('${_hlsTempDir.path}/$sessionId/$segment'); - if (!file.existsSync()) return Response.notFound('Segment not found'); - return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600)); - } - - router.get('///', - (Request request, String streamId, String offsetKey, String segment) { - return serveSegment(streamId, offsetKey, segment); - }); - - // Compat : playlist servie avant la mise en place des offsets, encore en - // cache dans un onglet ouvert. - router.get('//', - (Request request, String streamId, String segment) { - return serveSegment(streamId, recordingOffsetKey(0), segment); - }); - - return router.call; -} diff --git a/bin/api/upstream_probe.dart b/bin/api/upstream_probe.dart deleted file mode 100644 index 19e1476..0000000 --- a/bin/api/upstream_probe.dart +++ /dev/null @@ -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> 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.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> hops, Uri url})> - _open(http.Client c, Uri url, Stopwatch sw) async { - final hops = >[]; - 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().catchError((_) {}); - current = current.resolve(location); - continue; - } - return (response: response, hops: hops, url: current); - } - return (response: null, hops: hops, url: current); -} - -Future> _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(); - 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> _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}; - } -} diff --git a/bin/api/users_handler.dart b/bin/api/users_handler.dart deleted file mode 100644 index 886e71e..0000000 --- a/bin/api/users_handler.dart +++ /dev/null @@ -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('//password', _updatePassword); - router.put('/', _updateUser); - router.delete('/', _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 _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 _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; - 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 _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; - 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 _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; - 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 _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'}, - ); - } - } -} diff --git a/bin/api/xtream_api_handler.dart b/bin/api/xtream_api_handler.dart deleted file mode 100644 index 366c8a9..0000000 --- a/bin/api/xtream_api_handler.dart +++ /dev/null @@ -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 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 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 = { - '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'}, - ); - } - } -} diff --git a/bin/database/database.dart b/bin/database/database.dart deleted file mode 100644 index dfd5532..0000000 --- a/bin/database/database.dart +++ /dev/null @@ -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 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 = { - // 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 _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 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 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 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 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 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 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 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 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> getAllSeasonPasses() { - final result = _db.select( - 'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC', - ); - return result.map((r) => Map.from(r)).toList(); - } - - /// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API) - List> 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.from(r)).toList(); - } - - /// Récupérer un Season Pass par id (contrôle de propriété côté API) - Map? getSeasonPassById(String id) { - final result = - _db.select('SELECT * FROM season_passes WHERE id = ?', [id]); - if (result.isEmpty) return null; - return Map.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(); - } -} diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart deleted file mode 100644 index fb135fb..0000000 --- a/bin/middleware/auth_middleware.dart +++ /dev/null @@ -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/.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; -} diff --git a/bin/middleware/security_middleware.dart b/bin/middleware/security_middleware.dart deleted file mode 100644 index 9d307c3..0000000 --- a/bin/middleware/security_middleware.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:shelf/shelf.dart'; -import 'dart:async'; -import 'dart:io'; -import '../utils/log_redactor.dart'; - -/// Security Middleware Collection -/// -/// Includes: -/// - Redacted request logging -/// - Honeypot Routes (Trap for bots) -/// - Security Headers (HSTS, XSS Protection, CSP Report-Only) -/// - Rate Limiting (Basic DoS protection) -/// - Login-specific rate limiting (brute-force protection) - -/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré. -/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du -/// docker-compose parle depuis le réseau Docker). Surcharger avec -/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre. -final List _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/` 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 = >{}; - - // 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 = >{}; - - 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; - }; - }; -} diff --git a/bin/models/playlist.dart b/bin/models/playlist.dart deleted file mode 100644 index c7fcd08..0000000 --- a/bin/models/playlist.dart +++ /dev/null @@ -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 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 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 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(), - }; - } -} diff --git a/bin/models/playlist_config.dart b/bin/models/playlist_config.dart deleted file mode 100644 index b63248c..0000000 --- a/bin/models/playlist_config.dart +++ /dev/null @@ -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'; -} diff --git a/bin/models/recording.dart b/bin/models/recording.dart deleted file mode 100644 index e30bdca..0000000 --- a/bin/models/recording.dart +++ /dev/null @@ -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 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 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, - ); - } -} diff --git a/bin/models/session.dart b/bin/models/session.dart deleted file mode 100644 index bb057b9..0000000 --- a/bin/models/session.dart +++ /dev/null @@ -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 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 toMap() { - return { - 'id': id, - 'user_id': userId, - 'token': token, - 'expires_at': expiresAt.toIso8601String(), - 'created_at': createdAt.toIso8601String(), - }; - } - - bool get isExpired => DateTime.now().isAfter(expiresAt); -} diff --git a/bin/models/user.dart b/bin/models/user.dart deleted file mode 100644 index 84bbe49..0000000 --- a/bin/models/user.dart +++ /dev/null @@ -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 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 toJson() { - return { - 'id': id, - 'username': username, - 'isAdmin': isAdmin, - 'createdAt': createdAt.toIso8601String(), - }; - } -} diff --git a/bin/pubspec.lock b/bin/pubspec.lock deleted file mode 100644 index 318f579..0000000 --- a/bin/pubspec.lock +++ /dev/null @@ -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" diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml deleted file mode 100644 index 7aac5e9..0000000 --- a/bin/pubspec.yaml +++ /dev/null @@ -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 diff --git a/bin/server.dart b/bin/server.dart deleted file mode 100644 index 79b1e10..0000000 --- a/bin/server.dart +++ /dev/null @@ -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 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 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 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('/', recordingsApi.handleDelete) - ..post('/stop/', recordingsApi.handleStop) - ..get('/logs/', recordingsApi.getLogHandler); - - final epgRouter = Router()..get('/', epgApi.handleGetEpg); - - final seasonPassesRouter = Router() - ..get('/', seasonPassesApi.handleGetAll) - ..post('/', seasonPassesApi.handlePost) - ..delete('/', 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= : 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= 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=` 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 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/ - // 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 = { - 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); - }; - }; -} diff --git a/bin/services/cleanup_service.dart b/bin/services/cleanup_service.dart deleted file mode 100644 index 7a46f72..0000000 --- a/bin/services/cleanup_service.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:io'; -import 'dart:async'; - -class CleanupService { - final List _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> runCleanup() async { - print('CleanupService: Running cleanup task...'); - int deletedCount = 0; - int freedBytes = 0; - List 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); - } -} diff --git a/bin/services/ffmpeg_session_manager.dart b/bin/services/ffmpeg_session_manager.dart deleted file mode 100644 index 3cbde29..0000000 --- a/bin/services/ffmpeg_session_manager.dart +++ /dev/null @@ -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 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 _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 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 getOrStart({ - required String id, - required bool isLive, - required String ffmpegPath, - required List 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__t0`, `rec__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; - } - } -} diff --git a/bin/services/gpu_fallback.dart b/bin/services/gpu_fallback.dart deleted file mode 100644 index 8ebb45d..0000000 --- a/bin/services/gpu_fallback.dart +++ /dev/null @@ -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); -} diff --git a/bin/services/logo_catalog.dart b/bin/services/logo_catalog.dart deleted file mode 100644 index 7688ce0..0000000 --- a/bin/services/logo_catalog.dart +++ /dev/null @@ -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 _listings = {}; - final Map> _listingsInFlight = {}; - final Map _listingFailedAt = {}; - final Map> _bytes = {}; - - /// Octets PNG du logo correspondant à [channelName], ou `null`. - Future?> 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 = {}; - 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 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 _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 available) { - final keys = available.toSet(); - final tokens = _tokens(channelName); - if (tokens.isEmpty) return null; - - List withoutLive(List parts) => - parts.where((t) => t != 'live').toList(); - - String? exact(List parts) { - final slug = parts.join('-'); - return parts.isNotEmpty && keys.contains(slug) ? slug : null; - } - - String? longerVariant(List 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; -} diff --git a/bin/services/recording_decision.dart b/bin/services/recording_decision.dart deleted file mode 100644 index 2a8f315..0000000 --- a/bin/services/recording_decision.dart +++ /dev/null @@ -1,126 +0,0 @@ -/// Pure decision logic for the recording scheduler, extracted for testability. - -enum RecordingAction { - /// Start the recording now. - start, - - /// Capacity reached but the window is still open: retry on a later tick. - wait, - - /// The end time passed before the recording could start. - fail, - - /// Not yet due — nothing to do. - none, -} - -/// Decides what to do with a scheduled recording on a scheduler tick. -RecordingAction decideRecordingAction({ - required DateTime now, - required DateTime startTime, - required DateTime endTime, - required int activeCount, - required int maxConcurrent, -}) { - final nowUtc = now.toUtc(); - final startUtc = startTime.toUtc(); - final endUtc = endTime.toUtc(); - - if (nowUtc.isAfter(endUtc)) return RecordingAction.fail; - if (!nowUtc.isAfter(startUtc)) return RecordingAction.none; - if (activeCount >= maxConcurrent) return RecordingAction.wait; - return RecordingAction.start; -} - -/// Ce que le planificateur doit faire d'un enregistrement resté au statut -/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond -/// (redémarrage du conteneur, crash de l'isolate…). -enum OrphanAction { - /// La fenêtre est encore ouverte : relancer la capture. - resume, - - /// La fenêtre est passée mais un fichier partiel existe : le conserver. - finish, - - /// Rien d'exploitable n'a été capturé. - fail, -} - -/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick. -OrphanAction decideOrphanAction({ - required DateTime now, - required DateTime endTime, - required bool hasFile, - Duration minRemaining = const Duration(seconds: 60), -}) { - final remaining = endTime.toUtc().difference(now.toUtc()); - if (remaining > minRemaining) return OrphanAction.resume; - return hasFile ? OrphanAction.finish : OrphanAction.fail; -} - -/// Ce que le planificateur doit faire quand un processus FFmpeg se termine. -enum PostExitAction { - /// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé. - complete, - - /// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre. - retry, - - /// Arrêt prématuré sans rien d'exploitable. - fail, -} - -/// Nombre d'échecs FFmpeg consécutifs tolérés avant d'abandonner un -/// enregistrement dont la fenêtre est encore ouverte. -/// -/// Le compteur est remis à zéro dès qu'un lancement a duré assez longtemps -/// pour être considéré sain : une coupure toutes les dix minutes ne doit pas -/// finir par épuiser le quota. -const int maxFfmpegAttempts = 30; - -/// Décide de la suite à donner à la sortie d'un processus FFmpeg. -/// -/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une -/// coupure amont à mi-parcours ne doit pas condamner l'heure restante. -PostExitAction decidePostExitAction({ - required DateTime now, - required DateTime endTime, - required int exitCode, - required int consecutiveFailures, - required bool hasFile, - int maxAttempts = maxFfmpegAttempts, - Duration minRemaining = const Duration(seconds: 60), -}) { - final remaining = endTime.toUtc().difference(now.toUtc()); - if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) { - return PostExitAction.retry; - } - // `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement. - final cleanExit = exitCode == 0 || exitCode == 255; - return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail; -} - -/// Durée de capture à demander à FFmpeg (`-t`). -/// -/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur la -/// durée théorique du programme : un démarrage tardif (ou une relance après -/// coupure) ne doit pas décaler la fin de l'enregistrement. -Duration captureDuration({ - required DateTime now, - required DateTime endTime, - Duration minimum = const Duration(seconds: 30), -}) { - final remaining = endTime.toUtc().difference(now.toUtc()); - return remaining < minimum ? minimum : remaining; -} - -/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs. -/// -/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités -/// en connexions simultanées ; s'acharner à cette cadence pendant une panne -/// amont ne ferait qu'épuiser le quota de relances. -Duration ffmpegRetryDelay(int consecutiveFailures) { - final steps = (consecutiveFailures - 1).clamp(0, 4); - final seconds = 3 * (1 << steps); - return Duration(seconds: seconds > 30 ? 30 : seconds); -} diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart deleted file mode 100644 index 494c969..0000000 --- a/bin/services/recording_scheduler.dart +++ /dev/null @@ -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 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 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 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 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? extraParts, - }) : extraParts = extraParts ?? []; -} - -class RecordingScheduler { - final AppDatabase _db; - Timer? _timer; - Timer? _seasonPassTimer; - bool _isRunning = false; - - /// Enregistrements actuellement en cours, indexés par id. - final Map _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 shutdown({Duration timeout = const Duration(seconds: 8)}) async { - _timer?.cancel(); - _seasonPassTimer?.cancel(); - final closings = >[]; - 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 _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 _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 _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? ?? []; - - 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 _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.value()); - } - } - - /// Lance (ou relance) le processus FFmpeg d'un enregistrement actif. - Future _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 _pipeToLog(Stream> stream, _RecordingLog? log) { - return stream.forEach((chunk) => log?.add(chunk)).catchError((_) {}); - } - - /// Décide de la suite quand un processus FFmpeg se termine. - Future _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.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 _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 _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 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? _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 _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 _existingParts(String filePath) { - final parts = []; - 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 _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 _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 filesFor(String filePath) { - final paths = [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 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 _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', - ); - } - } -} diff --git a/bin/services/simple_recorder.dart b/bin/services/simple_recorder.dart deleted file mode 100644 index 8ab8e52..0000000 --- a/bin/services/simple_recorder.dart +++ /dev/null @@ -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 _active = {}; - - SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'}); - - /// Initialize recordings directory - Future init() async { - final dir = Directory(recordingsDir); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - print('🎬 Recorder ready -> $recordingsDir'); - } - - /// Start recording immediately - Future 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 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 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 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> 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 cleanupOld({int keepCount = 20}) async { - final dir = Directory(recordingsDir); - final files = dir.listSync().whereType().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, - }); -} diff --git a/bin/services/upstream_slots.dart b/bin/services/upstream_slots.dart deleted file mode 100644 index 23ea804..0000000 --- a/bin/services/upstream_slots.dart +++ /dev/null @@ -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 _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 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 = []; - 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 Function(PlaylistConfig) _fetch; - final Map _cache = {}; - final Set _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 Function(PlaylistConfig)? fetch, - }) : _fetch = fetch ?? _fetchFromPanel; - - Future 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.sync(() => _fetch(p)) - .then((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 _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(); - } - } -} diff --git a/bin/services/xmltv_epg_service.dart b/bin/services/xmltv_epg_service.dart deleted file mode 100644 index 2df8d25..0000000 --- a/bin/services/xmltv_epg_service.dart +++ /dev/null @@ -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 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> _index = {}; - DateTime? _indexedAt; - DateTime? _failedAt; - Future? _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> 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 _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 nameKeys(String name) { - final keys = [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 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 _refresh() async { - if (sourceUrls.isEmpty) return; - - final merged = >{}; - 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>> _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> _decodeAndParse( - Uint8List raw, { - required Duration retention, - required Duration horizon, - }) { - List 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> parseForTest(String xml) => - _parse(xml, retention: retention, horizon: horizon); - - static Map> _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 = >{}; - - // Alias : plusieurs dumps déclarent avec un ou plusieurs - // 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 = {}; - final displayNames = []; - - 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 toJson(String channelId) => { - 'title': title, - 'description': description, - 'start': start.toUtc().toIso8601String(), - 'end': stop.toUtc().toIso8601String(), - 'channel_id': channelId, - }; -} diff --git a/bin/test/asset_failure_cache_test.dart b/bin/test/asset_failure_cache_test.dart deleted file mode 100644 index ed424a2..0000000 --- a/bin/test/asset_failure_cache_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/asset_versioning_test.dart b/bin/test/asset_versioning_test.dart deleted file mode 100644 index aca9d67..0000000 --- a/bin/test/asset_versioning_test.dart +++ /dev/null @@ -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( - '', - {'xf-player-core.js': 'abc123'}, - ), - '', - ); - }); - - 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 = '' - ''; - expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html); - }); - - test('guillemets simples acceptés', () { - expect( - versionAssetRefs("", {'xf-player.css': '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)); - }); - }); -} diff --git a/bin/test/epg_api_test.dart b/bin/test/epg_api_test.dart deleted file mode 100644 index af67247..0000000 --- a/bin/test/epg_api_test.dart +++ /dev/null @@ -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 ''' - - - FRANCE 2 - - Journal de 20h - L'info du soir - - -'''; - } - - 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 = []; - - 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 = []; - // 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('', 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('', 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 = []; - 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('', 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('', 200); - final action = url.queryParameters['action'] ?? ''; - if (action == 'get_live_streams') { - liveStreamsCalls++; - await Future.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); - }); - }); -} diff --git a/bin/test/gpu_fallback_test.dart b/bin/test/gpu_fallback_test.dart deleted file mode 100644 index 5dec20b..0000000 --- a/bin/test/gpu_fallback_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/log_redactor_test.dart b/bin/test/log_redactor_test.dart deleted file mode 100644 index a7ddaec..0000000 --- a/bin/test/log_redactor_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/logo_api_test.dart b/bin/test/logo_api_test.dart deleted file mode 100644 index adf0f47..0000000 --- a/bin/test/logo_api_test.dart +++ /dev/null @@ -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 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('', 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); - }); - }); -} diff --git a/bin/test/logo_catalog_test.dart b/bin/test/logo_catalog_test.dart deleted file mode 100644 index 79a48fd..0000000 --- a/bin/test/logo_catalog_test.dart +++ /dev/null @@ -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 = []; - 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); - }); - }); -} diff --git a/bin/test/password_hasher_test.dart b/bin/test/password_hasher_test.dart deleted file mode 100644 index 47484c6..0000000 --- a/bin/test/password_hasher_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/player_log_test.dart b/bin/test/player_log_test.dart deleted file mode 100644 index a047fb2..0000000 --- a/bin/test/player_log_test.dart +++ /dev/null @@ -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('')); - }); - - 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'))); - }); - }); -} diff --git a/bin/test/recording_decision_test.dart b/bin/test/recording_decision_test.dart deleted file mode 100644 index 3ab5615..0000000 --- a/bin/test/recording_decision_test.dart +++ /dev/null @@ -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)); - }); - }); -} diff --git a/bin/test/recording_playlist_test.dart b/bin/test/recording_playlist_test.dart deleted file mode 100644 index f04a1fd..0000000 --- a/bin/test/recording_playlist_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/safe_path_test.dart b/bin/test/safe_path_test.dart deleted file mode 100644 index d3961ab..0000000 --- a/bin/test/safe_path_test.dart +++ /dev/null @@ -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, - ); - }); - }); -} diff --git a/bin/test/ssrf_test.dart b/bin/test/ssrf_test.dart deleted file mode 100644 index 5bf29a2..0000000 --- a/bin/test/ssrf_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/bin/test/stream_pipe_test.dart b/bin/test/stream_pipe_test.dart deleted file mode 100644 index c8c0d80..0000000 --- a/bin/test/stream_pipe_test.dart +++ /dev/null @@ -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>(); - final piped = pipeWithBackpressure(source.stream, onStop: () {}); - - final received = >[]; - final done = piped.listen(received.add).asFuture(); - - 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>( - onPause: () => paused = true, - onResume: () => resumed = true, - ); - - final subscription = pipeWithBackpressure( - source.stream, - onStop: () {}, - ).listen((_) {}); - - subscription.pause(); - await Future.delayed(Duration.zero); - expect(paused, isTrue); - - subscription.resume(); - await Future.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>(); - 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>(); - final piped = pipeWithBackpressure(source.stream, onStop: () => stopped++); - final done = piped.listen((_) {}).asFuture(); - - await source.close(); - await done; - - expect(stopped, 1); - }); - }); -} diff --git a/bin/test/upstream_probe_test.dart b/bin/test/upstream_probe_test.dart deleted file mode 100644 index cef3566..0000000 --- a/bin/test/upstream_probe_test.dart +++ /dev/null @@ -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'))); - }); -} diff --git a/bin/test/upstream_slots_test.dart b/bin/test/upstream_slots_test.dart deleted file mode 100644 index 8a595e3..0000000 --- a/bin/test/upstream_slots_test.dart +++ /dev/null @@ -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 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(); - 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.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.delayed(Duration.zero); - // Périmée (ttl nul) : la valeur connue sort sans attendre la nouvelle. - expect(await limits.maxFor(playlist()), 2); - await Future.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.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, - ); - }); - }); -} diff --git a/bin/test/vod_extension_test.dart b/bin/test/vod_extension_test.dart deleted file mode 100644 index 8e70f46..0000000 --- a/bin/test/vod_extension_test.dart +++ /dev/null @@ -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'); - }); - }); -} diff --git a/bin/test/xmltv_epg_log_redaction_test.dart b/bin/test/xmltv_epg_log_redaction_test.dart deleted file mode 100644 index 8a1d206..0000000 --- a/bin/test/xmltv_epg_log_redaction_test.dart +++ /dev/null @@ -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> _capturePrints(Future Function() body) async { - final lines = []; - 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('', 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')); - }); - }); -} diff --git a/bin/test/xmltv_epg_test.dart b/bin/test/xmltv_epg_test.dart deleted file mode 100644 index d896164..0000000 --- a/bin/test/xmltv_epg_test.dart +++ /dev/null @@ -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 ''' - - - FR - FRANCE 2 - - Journal de 20h - L'info du soir - - -'''; -} - -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) => ''' - $name - $title'''; - - test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async { - final now = DateTime.now().toUtc(); - final svc = serviceWith('${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}'); - 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('${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}'); - 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('${channel('TF1.fr', 'TF1', 'Le 13h', now)}'); - 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('' - '${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}' - '${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}'); - 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 = ''' - - TF1 HDTF1 - - Le 13h - - -'''; - 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(''' - - Premier NomSecond Nom - T - -'''); - 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(''); - for (var c = 0; c < 7000; c++) { - xml.write('' - 'FR - CHAINE $c'); - 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 $p$desc' - ''); - } - } - xml.write(''); - 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, - ); - }); - }); -} diff --git a/bin/utils/asset_versioning.dart b/bin/utils/asset_versioning.dart deleted file mode 100644 index 178f8c9..0000000 --- a/bin/utils/asset_versioning.dart +++ /dev/null @@ -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=` 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 bytes) => - md5.convert(bytes).toString().substring(0, 12); - -/// Ajoute `?v=` à 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 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 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 = {}; - - // 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 = { - 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; -} diff --git a/bin/utils/log_redactor.dart b/bin/utils/log_redactor.dart deleted file mode 100644 index e8d5593..0000000 --- a/bin/utils/log_redactor.dart +++ /dev/null @@ -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///.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; - } -} diff --git a/bin/utils/media_probe.dart b/bin/utils/media_probe.dart deleted file mode 100644 index d1ee891..0000000 --- a/bin/utils/media_probe.dart +++ /dev/null @@ -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 _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 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 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 _probe( - String path, { - required String field, - required List 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; -} diff --git a/bin/utils/password_hasher.dart b/bin/utils/password_hasher.dart deleted file mode 100644 index 416f010..0000000 --- a/bin/utils/password_hasher.dart +++ /dev/null @@ -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); -} diff --git a/bin/utils/safe_path.dart b/bin/utils/safe_path.dart deleted file mode 100644 index 21460d6..0000000 --- a/bin/utils/safe_path.dart +++ /dev/null @@ -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; - } -} diff --git a/bin/utils/stream_pipe.dart b/bin/utils/stream_pipe.dart deleted file mode 100644 index c4813e9..0000000 --- a/bin/utils/stream_pipe.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:async'; - -/// Relaie [source] vers le client en conservant la contre-pression. -/// -/// POURQUOI : un `StreamController` nu ne transmet pas la pause de son -/// abonné à la source. Quand le navigateur lit plus lentement que FFmpeg ne -/// produit — réseau domestique, onglet en arrière-plan —, le serveur empilait -/// donc les paquets en mémoire sans jamais ralentir le producteur : la -/// consommation grimpe et la lecture dérive derrière le direct, ce qui se voit -/// à l'écran comme des saccades. -/// -/// [onStop] est appelé une seule fois, quand le client se déconnecte ou que la -/// source se termine : c'est là qu'on tue le processus FFmpeg, sinon il en -/// reste un par zapping. -Stream> pipeWithBackpressure( - Stream> source, { - required void Function() onStop, -}) { - late final StreamController> controller; - late final StreamSubscription> subscription; - var stopped = false; - - void stop() { - if (stopped) return; - stopped = true; - onStop(); - } - - controller = StreamController>( - 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; -} diff --git a/build_release.ps1 b/build_release.ps1 index c07e0bb..531616e 100644 --- a/build_release.ps1 +++ b/build_release.ps1 @@ -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..." diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..01c2d21 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2063 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bcrypt" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" +dependencies = [ + "base64 0.23.1", + "blowfish", + "getrandom 0.4.3", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common 0.1.7", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xtremflow-server" +version = "0.1.0" +dependencies = [ + "async-stream", + "axum", + "base64 0.22.1", + "bcrypt", + "bytes", + "chrono", + "flate2", + "futures", + "libc", + "md-5", + "mime_guess", + "percent-encoding", + "quick-xml", + "regex", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..3728851 --- /dev/null +++ b/server/Cargo.toml @@ -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 diff --git a/server/src/api/account.rs b/server/src/api/account.rs new file mode 100644 index 0000000..3114b34 --- /dev/null +++ b/server/src/api/account.rs @@ -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) -> Resp { + json(status, &json!({"success": false, "error": error.into()})) +} + +fn str_field(payload: &Value, key: &str) -> Option { + 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 { + match serde_json::from_slice::(&req.body) { + Ok(v) if v.is_object() => Ok(v), + Ok(_) => Err(error_json( + 500, + "type is not a subtype of type 'Map'", + )), + 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 = 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 = 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::(&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(), + } +} diff --git a/server/src/api/admin.rs b/server/src/api/admin.rs new file mode 100644 index 0000000..150369a --- /dev/null +++ b/server/src/api/admin.rs @@ -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= 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, Vec, 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 = 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, + }) +} diff --git a/server/src/api/epg.rs b/server/src/api/epg.rs new file mode 100644 index 0000000..467d2c2 --- /dev/null +++ b/server/src/api/epg.rs @@ -0,0 +1,774 @@ +//! API EPG — `GET /api/epg/`, 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 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, + names: HashMap, + expires_at: Option, +} + +struct Inner { + xmltv: Option>, + 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>, + channel_maps: Mutex>>, + channel_maps_in_flight: Mutex>>>>, + panel_xmltv: Mutex>>, +} + +#[derive(Clone)] +pub struct EpgApi { + inner: Arc, +} + +/// 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::(); + 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 { + 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, HashMap) { + 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::(&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 { + 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 = 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, 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>, + 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, 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 = 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 { + 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>, + playlist: &PlaylistConfig, + channel_id: &str, + ) -> Vec { + 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 { + // `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::(&String::from_utf8_lossy(&body)) else { + continue; + }; + let programmes: Vec = 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 { + 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) -> (u16, String) + Send + Sync>; + + async fn mock(handler: Handler) -> String { + let app = axum::Router::new().fallback( + move |Query(q): Query>, 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) -> String { + d.format("%Y%m%d%H%M%S +0000").to_string() + } + + fn dump( + id: &str, + name: &str, + start: chrono::DateTime, + stop: chrono::DateTime, + ) -> String { + format!( + r#"{name}Journal de 20hL'info du soir"#, + 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::::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| 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, "".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, "".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::::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, "".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, "".into()); + } + if q.get("action").map(String::as_str) == Some("get_live_streams") { + c.fetch_add(1, Ordering::SeqCst); + let list: Vec = (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 = (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); + } +} diff --git a/server/src/api/logo.rs b/server/src/api/logo.rs new file mode 100644 index 0000000..c9881ca --- /dev/null +++ b/server/src/api/logo.rs @@ -0,0 +1,75 @@ +//! `GET /api/logo?src=&name=` +//! +//! 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() +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs new file mode 100644 index 0000000..bcb439e --- /dev/null +++ b/server/src/api/mod.rs @@ -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; diff --git a/server/src/api/player_log.rs b/server/src/api/player_log.rs new file mode 100644 index 0000000..12bb637 --- /dev/null +++ b/server/src/api/player_log.rs @@ -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 = LazyLock::new(|| Regex::new(r"https?://\S+").unwrap()); +static UNSAFE: LazyLock = 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 { + 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, ""); + 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::(&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("")); + } + + #[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")); + } +} diff --git a/server/src/api/proxy.rs b/server/src/api/proxy.rs new file mode 100644 index 0000000..a7b3232 --- /dev/null +++ b/server/src/api/proxy.rs @@ -0,0 +1,466 @@ +//! Proxy Xtream `/api/xtream/` (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>, + down_until: Mutex>, +} + +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::() 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>, +} + +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 { + 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, + ) -> Result { + let target = + target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?; + if target.scheme() != "http" && target.scheme() != "https" { + return Err(ProxyError::Response(http::text( + 403, + "Unsupported URL scheme", + ))); + } + let target_host = host_of(&target); + if is_forbidden_proxy_host(&target_host) { + println!("[Proxy] Blocked SSRF attempt to private host: {target_host}"); + return Err(ProxyError::Response(http::text( + 403, + "Access to this host is forbidden", + ))); + } + + // Images : tout hôte autorisé (CDN de picons des revendeurs). + let is_static_asset = is_static_asset_url(&target); + // Hôte d'images déjà constaté mort : réponse immédiate, sans appel + // sortant ni log — c'est précisément le flot qu'on veut éteindre. + if is_static_asset && self.asset_failures.is_down(&target_host) { + return Ok(missing_image_response()); + } + + let mut allowed_host = None; + if !is_static_asset { + // Appels d'API : allowlist de domaine. + let Some(playlist) = self.cached_playlist(state, req) else { + return Err(ProxyError::Response(http::text( + 403, + "No active playlist configuration found to validate request", + ))); + }; + let allowed = reqwest::Url::parse(&playlist.dns) + .map(|u| host_of(&u).to_lowercase()) + .unwrap_or_default(); + if target_host.to_lowercase() != allowed { + println!( + "[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})", + target_host.to_lowercase() + ); + return Err(ProxyError::Response(http::text( + 403, + "Access to this domain is forbidden by policy", + ))); + } + allowed_host = Some(allowed); + } + + let mut headers = reqwest::header::HeaderMap::new(); + let mut set = |name: &str, value: &str| { + if let (Ok(n), Ok(v)) = ( + reqwest::header::HeaderName::from_bytes(name.as_bytes()), + reqwest::header::HeaderValue::from_str(value), + ) { + headers.insert(n, v); + } + }; + set("user-agent", "VLC/3.0.18 LibVLC/3.0.18"); + set("accept", "*/*"); + set("accept-encoding", "identity"); + for name in ALLOWED_REQUEST_HEADERS { + if let Some(value) = req.header(name) { + set(name, value); + } + } + + println!("[Proxy] Forwarding to: {}", redact_url(target.as_str())); + let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes()) + .unwrap_or(reqwest::Method::GET); + let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone()); + + // Redirections suivies À LA MAIN : chaque destination est revalidée + // (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à + // atteindre un hôte interne malgré l'anti-SSRF. + let mut current = target.clone(); + let mut redirects = 0; + let response = loop { + let mut builder = state + .http_manual + .request(method.clone(), current.clone()) + .headers(headers.clone()); + if let Some(body) = &post_body { + builder = builder.body(body.clone()); + } + // 90 s : laisse le front (60 s) abandonner proprement le premier. + let response = tokio::time::timeout(Duration::from_secs(90), builder.send()) + .await + .map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))? + .map_err(|e| ProxyError::Failure(format!("{e:?}")))?; + + let status = response.status().as_u16(); + let location = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let Some(location) = location.filter(|_| (300..400).contains(&status)) else { + break response; + }; + redirects += 1; + if redirects > 3 { + return Err(ProxyError::Response(http::text(403, "Too many redirects"))); + } + current = current + .join(&location) + .map_err(|e| ProxyError::Failure(e.to_string()))?; + if current.scheme() != "http" && current.scheme() != "https" { + return Err(ProxyError::Response(http::text( + 403, + "Unsupported redirect scheme", + ))); + } + let host = host_of(¤t); + if is_forbidden_proxy_host(&host) { + println!("[Proxy] Blocked SSRF redirect to private host: {host}"); + return Err(ProxyError::Response(http::text( + 403, + "Access to this host is forbidden", + ))); + } + if let Some(allowed) = &allowed_host + && host.to_lowercase() != *allowed + { + println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})"); + return Err(ProxyError::Response(http::text( + 403, + "Access to this domain is forbidden by policy", + ))); + } + }; + + // Une image en erreur n'a rien à transmettre au navigateur : relayer + // le 503 déclenchait un nouveau cycle de requêtes à chaque rendu. + // L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus). + let status = response.status().as_u16(); + if is_static_asset { + if status >= 400 { + self.asset_failures.record_failure(&target_host); + return Ok(missing_image_response()); + } + self.asset_failures.record_success(&target_host); + } + + let upstream_headers = response.headers().clone(); + let mut resp = http::response( + status, + Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)), + ); + http::set_header(&mut resp, "access-control-allow-origin", "*"); + for name in ALLOWED_RESPONSE_HEADERS { + let values: Vec<&str> = upstream_headers + .get_all(name) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect(); + if !values.is_empty() { + http::set_header(&mut resp, name, &values.join(", ")); + } + } + Ok(resp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bloque_le_loopback() { + assert!(is_forbidden_proxy_host("127.0.0.1")); + assert!(is_forbidden_proxy_host("localhost")); + assert!(is_forbidden_proxy_host("::1")); + assert!(is_forbidden_proxy_host("[::1]")); + } + + #[test] + fn bloque_les_plages_privees() { + assert!(is_forbidden_proxy_host("10.0.0.5")); + assert!(is_forbidden_proxy_host("172.16.0.1")); + assert!(is_forbidden_proxy_host("172.31.255.255")); + assert!(is_forbidden_proxy_host("192.168.1.10")); + assert!(is_forbidden_proxy_host("169.254.169.254")); + assert!(is_forbidden_proxy_host("0.0.0.0")); + } + + #[test] + fn autorise_les_ip_publiques_et_les_noms() { + assert!(!is_forbidden_proxy_host("8.8.8.8")); + assert!(!is_forbidden_proxy_host("172.32.0.1")); + assert!(!is_forbidden_proxy_host("cdn.example.com")); + } + + #[test] + fn cache_des_hotes_morts() { + let cache = AssetFailureCache::new(3, Duration::from_secs(300)); + let t0 = Instant::now(); + assert!(!cache.is_down_at("cdn.example", t0)); + cache.record_failure_at("cdn.example", t0); + assert!(!cache.is_down_at("cdn.example", t0)); + cache.record_failure_at("cdn.example", t0); + cache.record_success("cdn.example"); + cache.record_failure_at("cdn.example", t0); + assert!(!cache.is_down_at("cdn.example", t0)); + cache.record_failure_at("CDN.example", t0); + cache.record_failure_at("cdn.example", t0); + assert!(cache.is_down_at("cdn.example", t0)); + // Coupure expirée : ardoise vierge. + assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301))); + cache.record_failure_at("cdn.example", t0); + assert!(!cache.is_down_at("cdn.example", t0)); + } +} diff --git a/server/src/api/recording_playlist.rs b/server/src/api/recording_playlist.rs new file mode 100644 index 0000000..af8aa3a --- /dev/null +++ b/server/src/api/recording_playlist.rs @@ -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=`), bornée au positif. +pub fn parse_recording_start(raw: Option<&str>) -> u64 { + let Some(value) = raw.and_then(|r| r.trim().parse::().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 { + let digits = key.strip_prefix('t')?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit() || b == b'+') { + return None; + } + digits.parse::().ok() +} + +static SEGMENT_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^(segment_\d+\.ts)\s*$").unwrap()); + +/// Préfixe les lignes de segment de [playlist] par [offset_key] ; balises +/// `#EXT-X-…` et lignes vides passent telles quelles. +pub fn rewrite_recording_playlist(playlist: &str, offset_key: &str) -> String { + SEGMENT_LINE + .replace_all(playlist, |caps: ®ex::Captures| { + format!("{offset_key}/{}", &caps[1]) + }) + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lit_la_position() { + assert_eq!(parse_recording_start(Some("2700")), 2700); + assert_eq!(parse_recording_start(Some("2700.9")), 2700); + for bad in [ + None, + Some(""), + Some("abc"), + Some("-30"), + Some("NaN"), + Some("Infinity"), + ] { + assert_eq!(parse_recording_start(bad), 0); + } + } + + #[test] + fn cle_d_offset_aller_retour() { + for start in [0, 1, 2700, 86400] { + assert_eq!( + parse_recording_offset_key(&recording_offset_key(start)), + Some(start) + ); + } + for bad in ["t", "segment_000.ts", "..", "t-30", "tabc"] { + assert_eq!(parse_recording_offset_key(bad), None, "{bad}"); + } + } + + const PLAYLIST: &str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXTINF:4.000000,\nsegment_000.ts\n#EXTINF:4.000000,\nsegment_001.ts\n"; + + #[test] + fn rattache_chaque_segment_a_sa_session() { + let out = rewrite_recording_playlist(PLAYLIST, "t2700"); + assert!(out.contains("t2700/segment_000.ts")); + assert!(out.contains("t2700/segment_001.ts")); + assert!(!out.contains("\nsegment_")); + } + + #[test] + fn laisse_les_balises() { + let out = rewrite_recording_playlist(PLAYLIST, "t0"); + assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:3\n")); + assert!(out.contains("#EXT-X-PLAYLIST-TYPE:EVENT")); + assert_eq!(out.matches("#EXTINF:4.000000,").count(), 2); + } + + #[test] + fn idempotent() { + let once = rewrite_recording_playlist(PLAYLIST, "t60"); + assert_eq!(rewrite_recording_playlist(&once, "t60"), once); + } +} diff --git a/server/src/api/recordings.rs b/server/src/api/recordings.rs new file mode 100644 index 0000000..f62eb16 --- /dev/null +++ b/server/src/api/recordings.rs @@ -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 { + 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) -> Map { + 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 = 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> { + 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 { + 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::(&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::(&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()); + } +} diff --git a/server/src/api/static_files.rs b/server/src/api/static_files.rs new file mode 100644 index 0000000..a2bc988 --- /dev/null +++ b/server/src/api/static_files.rs @@ -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=`) 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 { + 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 = 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 = 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 { + 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") + ); + } +} diff --git a/server/src/api/streaming.rs b/server/src/api/streaming.rs new file mode 100644 index 0000000..b87d5a9 --- /dev/null +++ b/server/src/api/streaming.rs @@ -0,0 +1,1328 @@ +//! Streaming : direct (HLS transcodé, `turbo.ts`, proxy `.ts` brut), films +//! et séries (HLS), lecture des enregistrements (HLS par offset). + +use crate::api::recording_playlist::{ + parse_recording_offset_key, parse_recording_start, recording_offset_key, + rewrite_recording_playlist, +}; +use crate::http::{self, Req, Resp, segments}; +use crate::models::PlaylistConfig; +use crate::services::ffmpeg_sessions::{FfmpegSession, FfmpegSessionManager}; +use crate::services::gpu_fallback::{GpuHealth, is_gpu_failure}; +use crate::services::upstream_slots::{AccountLimits, UpstreamSlots, account_key_of}; +use crate::state::AppState; +use crate::util::log_redactor::redact_url; +use crate::util::media_probe; +use crate::util::process::ManagedChild; +use axum::body::Body; +use bytes::Bytes; +use futures::StreamExt; +use regex::Regex; +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio_util::io::ReaderStream; + +/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin. +/// +/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux +/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau +/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes. +const VIEWER_GRACE: Duration = Duration::from_secs(15); + +/// Reconnexions consécutives tentées avant d'abandonner un proxy `.ts`. +const LIVE_PROXY_MAX_RETRIES: u32 = 4; + +const VLC_UA: &str = "VLC/3.0.18 LibVLC/3.0.18"; + +pub struct Streaming { + pub sessions: Arc, + /// Pannes GPU récentes, partagées par toutes les routes de transcodage. + gpu_health: GpuHealth, + /// Connexions de lecture ouvertes chez le fournisseur, par compte. + slots: Arc, + /// Quota `max_connections` de chaque compte Xtream. + limits: AccountLimits, + /// Numérote les flux `turbo.ts` (un FFmpeg par requête). + turbo_counter: AtomicU64, +} + +struct Attempt { + session: Option>, + ready: bool, + error: Option, +} + +/// Binaire FFmpeg du streaming (PATH ; exécutable portable sous Windows). +fn ffmpeg_path() -> String { + if cfg!(windows) { + for candidate in ["ffmpeg.exe", "bin/ffmpeg.exe", "ffmpeg/bin/ffmpeg.exe"] { + if Path::new(candidate).exists() { + return candidate.to_string(); + } + } + } + "ffmpeg".to_string() +} + +/// `source` copie la vidéo telle quelle (`-c:v copy`) : énorme gain CPU, la +/// plupart des flux Xtream étant déjà en H.264. L'audio est toujours +/// normalisé en AAC pour la compatibilité HLS. +fn sanitize_quality(quality: Option<&str>) -> &'static str { + match quality { + Some("source") => "source", + Some("medium") => "medium", + Some("low") => "low", + _ => "high", + } +} + +static STREAM_ID: LazyLock = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap()); +static CONTAINER_EXT: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z0-9]{2,5}$").unwrap()); + +fn is_valid_stream_id(id: &str) -> bool { + STREAM_ID.is_match(id) +} + +/// Extension de conteneur d'un film/épisode, validée (elle finit dans l'URL +/// du panneau). `mkv` par défaut : les anciens clients sans `ext` gardent +/// leur comportement. +pub fn vod_container_extension(ext: Option<&str>) -> String { + match ext.map(str::to_lowercase) { + Some(v) if CONTAINER_EXT.is_match(&v) => v, + _ => "mkv".to_string(), + } +} + +fn strings(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() +} + +/// Arguments vidéo du profil direct. +fn live_video_args(quality: &str, gpu: bool) -> Vec { + strings(match (quality, gpu) { + ("source", _) => &["-c:v", "copy"], + ("medium", true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-tune", + "hq", + "-b:v", + "3000k", + "-maxrate", + "4500k", + "-bufsize", + "6000k", + "-profile:v", + "high", + "-level", + "4.0", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + ("medium", false) => &[ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-tune", + "zerolatency", + "-profile:v", + "high", + "-level", + "4.0", + "-b:v", + "3000k", + "-maxrate", + "4500k", + "-bufsize", + "6000k", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + ("low", true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-b:v", + "1500k", + "-maxrate", + "2000k", + "-bufsize", + "3000k", + "-vf", + "scale=-2:720", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + ("low", false) => &[ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-tune", + "zerolatency", + "-b:v", + "1500k", + "-maxrate", + "2000k", + "-bufsize", + "3000k", + "-vf", + "scale=-2:720", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + (_, true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-tune", + "hq", + "-b:v", + "8000k", + "-maxrate", + "12000k", + "-bufsize", + "16000k", + "-profile:v", + "high", + "-level", + "4.0", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + // veryfast et non medium : en direct, l'encodeur doit tenir le temps + // réel ET produire le premier segment vite. + (_, false) => &[ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-tune", + "zerolatency", + "-profile:v", + "high", + "-level", + "4.0", + "-b:v", + "6000k", + "-maxrate", + "8000k", + "-bufsize", + "12000k", + "-pix_fmt", + "yuv420p", + "-g", + "50", + ], + }) +} + +/// Arguments vidéo du profil film/série. +fn vod_video_args(quality: &str, gpu: bool) -> Vec { + strings(match (quality, gpu) { + ("source", _) => &["-c:v", "copy"], + ("medium", true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-tune", + "hq", + "-rc", + "cbr", + "-b:v", + "2500k", + "-maxrate", + "3000k", + "-bufsize", + "5000k", + "-g", + "48", + "-bf", + "2", + "-pix_fmt", + "yuv420p", + ], + ("medium", false) => &[ + "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-maxrate", "6000k", "-bufsize", + "12000k", "-pix_fmt", "yuv420p", "-g", "48", "-threads", "0", + ], + ("low", true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-rc", + "cbr", + "-b:v", + "1500k", + "-maxrate", + "2000k", + "-bufsize", + "3000k", + "-vf", + "scale=-2:720", + "-g", + "48", + "-pix_fmt", + "yuv420p", + ], + ("low", false) => &[ + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "26", + "-maxrate", + "3000k", + "-bufsize", + "6000k", + "-vf", + "scale=-2:720", + "-pix_fmt", + "yuv420p", + "-g", + "48", + "-threads", + "0", + ], + (_, true) => &[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-tune", + "hq", + "-rc", + "cbr", + "-b:v", + "4000k", + "-maxrate", + "4500k", + "-bufsize", + "8000k", + "-g", + "48", + "-bf", + "2", + "-pix_fmt", + "yuv420p", + ], + (_, false) => &[ + "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-maxrate", "12000k", "-bufsize", + "24000k", "-pix_fmt", "yuv420p", "-g", "48", "-threads", "0", + ], + }) +} + +/// Audio : AAC simple en `source`, downmix + normalisation sinon. +fn audio_args(with_filters: bool) -> Vec { + let mut args = strings(&[ + "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000", "-af", + ]); + args.push(if with_filters { + "pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15".to_string() + } else { + "aresample=async=1".to_string() + }); + args +} + +fn hls_headers(resp: Resp) -> Resp { + http::with_headers( + resp, + &[ + ("content-type", "application/vnd.apple.mpegurl"), + ("cache-control", "no-cache"), + ("x-content-type-options", "nosniff"), + ], + ) +} + +/// Sert un segment `.ts` d'une session (404 s'il n'existe pas/plus). +async fn serve_segment(path: &Path, max_age: u32) -> Resp { + match tokio::fs::File::open(path).await { + Ok(file) => http::with_headers( + http::response(200, Body::from_stream(ReaderStream::new(file))), + &[ + ("content-type", "video/mp2t"), + ("cache-control", &format!("max-age={max_age}")), + ], + ), + Err(_) => http::text(404, "Segment not found"), + } +} + +/// Termine FFmpeg quand le corps de réponse est abandonné (client parti). +struct TerminateOnDrop(ManagedChild); + +impl Drop for TerminateOnDrop { + fn drop(&mut self) { + self.0.terminate(); + } +} + +impl Streaming { + pub fn new(sessions: Arc, panel_client: reqwest::Client) -> Self { + Self { + sessions, + gpu_health: GpuHealth::default(), + slots: Arc::new(UpstreamSlots::new()), + limits: AccountLimits::from_panel(panel_client), + turbo_counter: AtomicU64::new(0), + } + } + + /// Fait de la place chez le fournisseur avant d'ouvrir la connexion [id]. + async fn claim_upstream(&self, playlist: &PlaylistConfig, id: &str) { + let max = self.limits.max_for(playlist); + let freed = self.slots.make_room(&account_key_of(playlist), max, id); + if freed.is_empty() { + return; + } + println!( + "[Upstream] {id} : {} coupé(s) (quota fournisseur : {max} connexion(s))", + freed.join(", ") + ); + // Laisser le panneau constater la fermeture : rouvrir aussitôt se + // fait refuser sur les comptes à une seule connexion. + tokio::time::sleep(Duration::from_secs(1)).await; + } + + /// Inscrit [child] comme connexion amont de [playlist] tant qu'il vit. + fn register_upstream( + &self, + playlist: &PlaylistConfig, + id: &str, + child: ManagedChild, + release: Box, + idle_for: Box Duration + Send + Sync>, + ) { + let token = self.slots.register( + id, + &account_key_of(playlist), + release, + Some(Box::new(move || idle_for() < VIEWER_GRACE)), + ); + let slots = self.slots.clone(); + let id = id.to_string(); + tokio::spawn(async move { + child.wait().await; + slots.unregister(&id, Some(token)); + }); + } + + /// Démarre (ou récupère) la session [id] et attend sa playlist. + /// [upstream] : playlist dont la session ouvre une connexion chez le + /// fournisseur (direct, film) ; `None` pour un enregistrement sur disque. + async fn run_session( + &self, + id: &str, + is_live: bool, + args: &[String], + min_segments: usize, + upstream: Option<&PlaylistConfig>, + ) -> Attempt { + let starting = self.sessions.needs_start(id, is_live); + if let (Some(playlist), true) = (upstream, starting) { + self.claim_upstream(playlist, id).await; + } + let session = match self + .sessions + .get_or_start(id, is_live, &ffmpeg_path(), args) + { + Ok(s) => s, + // Serveur à court de processus/mémoire : échec ordinaire, pas un 500. + Err(e) => { + return Attempt { + session: None, + ready: false, + error: Some(format!("FFmpeg start failed: {e}")), + }; + } + }; + if let (Some(playlist), true) = (upstream, starting) { + let sessions = self.sessions.clone(); + let release_id = id.to_string(); + let watched = session.clone(); + self.register_upstream( + playlist, + id, + session.child.clone(), + Box::new(move || sessions.kill_session(&release_id)), + // Touchée à chaque requête de playlist ou de segment. + Box::new(move || watched.idle_for()), + ); + } + let (ready, error) = self + .sessions + .wait_for_playlist(&session, min_segments) + .await; + Attempt { + session: Some(session), + ready, + error, + } + } + + /// Comme [run_session], avec repli CPU si l'encodage GPU échoue. Le repli + /// garde le même identifiant : le lecteur ne s'aperçoit de rien. + async fn run_with_gpu_fallback( + &self, + id: &str, + is_live: bool, + want_gpu: bool, + build_args: impl Fn(bool) -> Vec, + min_segments: usize, + upstream: Option<&PlaylistConfig>, + ) -> Attempt { + let gpu = want_gpu && self.gpu_health.available(); + let mut attempt = self + .run_session(id, is_live, &build_args(gpu), min_segments, upstream) + .await; + if !attempt.ready && gpu && is_gpu_failure(attempt.error.as_deref()) { + self.gpu_health.mark_failed(); + println!( + "[GPU] {id} : encodage GPU refusé ({}) — repli sur le CPU", + redact_url(attempt.error.as_deref().unwrap_or("")) + ); + self.sessions.kill_session(id); + attempt = self + .run_session(id, is_live, &build_args(false), min_segments, upstream) + .await; + } + attempt + } + + async fn serve_session_playlist(&self, id: &str, attempt: Attempt, label: &str) -> Resp { + let session = match attempt.session { + Some(s) if attempt.ready => s, + _ => { + self.sessions.kill_session(id); + // stderr de FFmpeg : peut recopier l'URL amont, identifiants + // compris — masqué avant d'être rendu au client. + return http::text( + 502, + format!( + "{label} transcoder failed: {}", + redact_url(attempt.error.as_deref().unwrap_or("null")) + ), + ); + } + }; + session.touch(); + match tokio::fs::read(session.playlist_path()).await { + Ok(bytes) => hls_headers(http::response(200, bytes)), + Err(_) => http::text(502, format!("{label} transcoder failed: playlist missing")), + } + } + + // ========================================== + // 1. DIRECT + // ========================================== + + pub async fn live(&self, state: &AppState, req: &Req, rest: &str) -> Resp { + if !req.is_get() { + return http::not_found(); + } + match segments(rest).as_slice() { + [id, quality, "playlist.m3u8"] => { + self.live_playlist(state, req, id, sanitize_quality(Some(quality))) + .await + } + // Ancienne route (?quality=…) : redirection pour que les URL + // relatives des segments se résolvent sous le chemin de qualité. + [id, "playlist.m3u8"] => { + let quality = sanitize_quality(req.query_param("quality").as_deref()); + http::redirect(&format!("/api/live/{id}/{quality}/playlist.m3u8")) + } + [id, "turbo.ts"] => self.turbo(state, req, id).await, + [file] if file.len() > 3 && file.ends_with(".ts") => { + self.direct_proxy(state, req, &file[..file.len() - 3]).await + } + [id, quality, segment] => { + if !is_valid_stream_id(id) || segment.contains("..") { + return http::text(400, "Invalid request"); + } + let session_id = format!("live_{id}_{}", sanitize_quality(Some(quality))); + self.sessions.touch(&session_id); + serve_segment(&self.sessions.base_dir.join(&session_id).join(segment), 60).await + } + _ => http::not_found(), + } + } + + async fn live_playlist( + &self, + state: &AppState, + req: &Req, + stream_id: &str, + quality: &str, + ) -> Resp { + if !is_valid_stream_id(stream_id) { + return http::text(400, "Invalid stream ID"); + } + let Some(playlist) = state.get_playlist(req) else { + return http::text(403, "No playlist"); + }; + let target = format!( + "{}/live/{}/{}/{stream_id}.ts", + playlist.dns, playlist.username, playlist.password + ); + let use_gpu = state.db.is_nvidia_gpu_enabled(); + let session_id = format!("live_{stream_id}_{quality}"); + if !self.sessions.contains(&session_id) { + println!("[Live HLS] Starting {session_id}: {}", redact_url(&target)); + } + + let build = |gpu: bool| { + let mut args = strings(&["-hide_banner", "-loglevel", "warning"]); + if gpu { + args.extend(strings(&["-hwaccel", "cuda"])); + } + args.extend(strings(&[ + "-headers", + "User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n", + "-reconnect", + "1", + "-reconnect_streamed", + "1", + "-reconnect_at_eof", + "1", + "-reconnect_delay_max", + "10", + // Lecture bloquée 30 s → reconnexion plutôt que blocage éternel. + "-rw_timeout", + "30000000", + // Démarrage rapide : sans borne, FFmpeg sonde plusieurs secondes. + "-fflags", + "nobuffer", + "-probesize", + "1000000", + "-analyzeduration", + "1000000", + "-i", + ])); + args.push(target.clone()); + args.extend(live_video_args(quality, gpu)); + args.extend(audio_args(false)); + // Fenêtre glissante 10 × 2 s ; premier segment clos après ~1 s. + args.extend(strings(&[ + "-f", + "hls", + "-hls_init_time", + "1", + "-hls_time", + "2", + "-hls_list_size", + "10", + "-hls_flags", + "delete_segments+independent_segments", + "-hls_segment_type", + "mpegts", + "-hls_segment_filename", + "seg_%03d.ts", + "playlist.m3u8", + ])); + args + }; + + let attempt = self + .run_with_gpu_fallback( + &session_id, + true, + use_gpu && quality != "source", + build, + 1, + Some(&playlist), + ) + .await; + self.serve_session_playlist(&session_id, attempt, "Live") + .await + } + + /// `/api/live/{id}/turbo.ts` — flux MPEG-TS continu pour mpegts.js : vidéo + /// copiée, audio TOUJOURS réencodé en AAC. + /// + /// POURQUOI : mpegts.js ne démuxe que l'AAC et le MP3 ; les chaînes en + /// AC-3/E-AC-3 ou MPEG-1 Layer II donnaient une image sans aucun son. Le + /// réencodage audio seul coûte quelques % de CPU et garde le zapping + /// instantané (pas de segmentation HLS à amorcer). + async fn turbo(&self, state: &AppState, req: &Req, stream_id: &str) -> Resp { + if !is_valid_stream_id(stream_id) { + return http::text(400, "Invalid stream ID"); + } + let Some(playlist) = state.get_playlist(req) else { + return http::text(403, "No playlist"); + }; + let target = format!( + "{}/live/{}/{}/{stream_id}.ts", + playlist.dns, playlist.username, playlist.password + ); + println!("[Live Turbo] {stream_id}: {}", redact_url(&target)); + + // Un identifiant par requête : deux onglets sur la même chaîne sont + // deux connexions distinctes chez le fournisseur. + let n = self.turbo_counter.fetch_add(1, Ordering::SeqCst) + 1; + let upstream_id = format!("turbo_{stream_id}_{n}"); + self.claim_upstream(&playlist, &upstream_id).await; + + let mut args = strings(&[ + "-hide_banner", + "-loglevel", + "warning", + "-headers", + "User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n", + "-reconnect", + "1", + "-reconnect_streamed", + "1", + "-reconnect_at_eof", + "1", + "-reconnect_delay_max", + "10", + "-rw_timeout", + "30000000", + // Démarrage rapide : pas de tampon d'analyse, sonde réduite. + "-fflags", + "nobuffer", + "-flags", + "low_delay", + "-probesize", + "1000000", + "-analyzeduration", + "1000000", + "-i", + ]); + args.push(target); + args.extend(strings(&[ + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "160k", + "-ac", + "2", + "-ar", + "48000", + "-af", + "aresample=async=1", + // Pas de délai de mux : les paquets partent dès qu'ils existent. + "-muxdelay", + "0", + "-muxpreload", + "0", + "-f", + "mpegts", + "pipe:1", + ])); + + let spawned = tokio::process::Command::new(ffmpeg_path()) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let mut child = match spawned { + Ok(c) => c, + Err(e) => { + // Serveur saturé : un 503 que le lecteur sait traiter. + println!( + "[Live Turbo] {stream_id} : FFmpeg n'a pas démarré ({})", + redact_url(&e.to_string()) + ); + return http::text(503, "FFmpeg start failed"); + } + }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let process = ManagedChild::spawn(child); + + // Dernier octet remis au client : un lecteur vivant en reçoit à chaque + // rafale ; un client parti n'en reçoit plus (contre-pression). + let started = Instant::now(); + let last_delivered = Arc::new(AtomicU64::new(0)); + let ld = last_delivered.clone(); + let release_process = process.clone(); + self.register_upstream( + &playlist, + &upstream_id, + process.clone(), + Box::new(move || release_process.terminate()), + Box::new(move || { + let elapsed = started.elapsed().as_millis() as u64; + Duration::from_millis(elapsed.saturating_sub(ld.load(Ordering::Relaxed))) + }), + ); + + // Erreurs FFmpeg journalisées (masquées) sans bloquer le flux. + if let Some(stderr) = stderr { + let id = stream_id.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let trimmed = line.trim(); + if !trimmed.is_empty() { + println!("[Live Turbo] {id} ffmpeg: {}", redact_url(trimmed)); + } + } + }); + } + + let Some(stdout) = stdout else { + process.terminate(); + return http::text(503, "FFmpeg start failed"); + }; + // Relais avec contre-pression (hyper ne lit stdout qu'au rythme du + // client) ; FFmpeg est tué dès que le client zappe ou ferme l'onglet, + // sinon les processus s'accumulent à chaque zap. + let guard = TerminateOnDrop(process); + let body = ReaderStream::new(stdout).map(move |chunk| { + let _keep = &guard; + last_delivered.store(started.elapsed().as_millis() as u64, Ordering::Relaxed); + chunk + }); + http::with_headers( + http::response(200, Body::from_stream(body)), + &[ + ("content-type", "video/mp2t"), + ("cache-control", "no-store"), + ], + ) + } + + /// `/api/live/{id}.ts` — proxy brut (enregistrements, lecture directe), + /// avec reconnexion amont. + /// + /// Les panneaux ferment régulièrement la connexion en cours de route + /// (bascule de source, limite de connexions, recyclage nginx). Sans + /// reprise, le `MediaSource` du navigateur s'arrête net (« ça coupe au + /// bout de 30 s ») : on rouvre la source tant que le client écoute. + async fn direct_proxy(&self, state: &AppState, req: &Req, stream_id: &str) -> Resp { + let Some(playlist) = state.get_playlist(req) else { + return http::text(403, "No playlist"); + }; + let target = format!( + "{}/live/{}/{}/{stream_id}.ts", + playlist.dns, playlist.username, playlist.password + ); + println!( + "[Live Proxy] Forwarding {stream_id}: {}", + redact_url(&target) + ); + + // Première connexion hors du générateur : elle seule décide du code + // de réponse. + let client = state.http.clone(); + let Some(first) = open_live_upstream(&client, &target).await else { + return http::text(502, "Upstream connection failed"); + }; + let status = first.status().as_u16(); + if status != 200 { + return http::text(status, "Upstream refused"); + } + + let id = stream_id.to_string(); + let body = async_stream::stream! { + let mut upstream = first; + let mut attempt = 0; + loop { + let started = Instant::now(); + let mut bytes: u64 = 0; + let mut stream = upstream.bytes_stream(); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => { + bytes += chunk.len() as u64; + yield Ok::(chunk); + } + Err(e) => { + // L'erreur recopie l'URL amont, identifiants compris. + println!( + "[Live Proxy] {id} : coupure amont ({})", + redact_url(&format!("{e:?}")) + ); + break; + } + } + } + drop(stream); + let lasted = started.elapsed(); + // Une connexion restée saine remet le quota de reprises à zéro : + // une coupure toutes les 10 minutes ne doit pas l'épuiser. + if lasted > Duration::from_secs(60) && bytes > 0 { + attempt = 0; + } + println!("[Live Proxy] {id} : flux terminé après {}s ({bytes} octets)", lasted.as_secs()); + + let mut next = None; + while next.is_none() && attempt < LIVE_PROXY_MAX_RETRIES { + attempt += 1; + // Laisser le panneau libérer le slot précédent. + tokio::time::sleep(Duration::from_secs(1)).await; + match open_live_upstream(&client, &target).await { + Some(r) if r.status().as_u16() == 200 => next = Some(r), + _ => println!( + "[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée" + ), + } + } + match next { + Some(r) => upstream = r, + None => { + println!("[Live Proxy] {id} : abandon, source injoignable"); + break; + } + } + } + }; + http::with_headers( + http::response(200, Body::from_stream(body)), + &[("content-type", "video/mp2t")], + ) + } + + // ========================================== + // 2. FILMS / SÉRIES + // ========================================== + + pub async fn vod(&self, state: &AppState, req: &Req, rest: &str) -> Resp { + if !req.is_get() { + return http::not_found(); + } + match segments(rest).as_slice() { + [id, quality, "playlist.m3u8"] => { + self.vod_playlist(state, req, id, sanitize_quality(Some(quality))) + .await + } + [id, "playlist.m3u8"] => { + let params = req.query_params(); + let quality = sanitize_quality(params.get("quality").map(String::as_str)); + let mut query = url::form_urlencoded::Serializer::new(String::new()); + for key in ["type", "ext"] { + if let Some(value) = params.get(key) { + query.append_pair(key, value); + } + } + let query = query.finish(); + let suffix = if query.is_empty() { + String::new() + } else { + format!("?{query}") + }; + http::redirect(&format!("/api/vod/{id}/{quality}/playlist.m3u8{suffix}")) + } + [id, quality, segment] => { + if !is_valid_stream_id(id) || segment.contains("..") { + return http::text(400, "Invalid request"); + } + let session_id = format!("vod_{id}_{}", sanitize_quality(Some(quality))); + self.sessions.touch(&session_id); + serve_segment( + &self.sessions.base_dir.join(&session_id).join(segment), + 3600, + ) + .await + } + _ => http::not_found(), + } + } + + async fn vod_playlist( + &self, + state: &AppState, + req: &Req, + stream_id: &str, + quality: &str, + ) -> Resp { + if !is_valid_stream_id(stream_id) { + return http::text(400, "Invalid stream ID"); + } + let Some(playlist) = state.get_playlist(req) else { + return http::text(500, "No playlist"); + }; + let params = req.query_params(); + // ?type=series ou ?type=movie choisit le chemin amont. + let content_type = params + .get("type") + .cloned() + .unwrap_or_else(|| "movie".into()); + let base_path = if content_type == "series" { + "series" + } else { + "movie" + }; + // Extension réelle (`container_extension`) : le panneau refuse un + // `.mkv` imposé à un film stocké en `.mp4`. + let target = format!( + "{}/{base_path}/{}/{}/{stream_id}.{}", + playlist.dns, + playlist.username, + playlist.password, + vod_container_extension(params.get("ext").map(String::as_str)) + ); + let use_gpu = state.db.is_nvidia_gpu_enabled(); + let session_id = format!("vod_{stream_id}_{quality}"); + if !self.sessions.contains(&session_id) { + println!( + "[VOD] Starting {session_id} ({content_type}): {}", + redact_url(&target) + ); + } + + let build = |gpu: bool| { + let mut args = strings(&["-hide_banner", "-loglevel", "warning"]); + if gpu { + args.extend(strings(&["-hwaccel", "cuda"])); + } + args.extend(strings(&[ + "-headers", + "User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n", + "-reconnect", + "1", + "-reconnect_at_eof", + "1", + "-reconnect_streamed", + "1", + "-reconnect_delay_max", + "10", + "-rw_timeout", + "30000000", + "-timeout", + "30000000", + // Sonde réduite : sur un amont lent, 10 Mo avant la première + // image ajoutaient plusieurs secondes au démarrage. + "-analyzeduration", + "2000000", + "-probesize", + "5000000", + "-i", + ])); + args.push(target.clone()); + args.extend(vod_video_args(quality, gpu)); + args.extend(audio_args(quality != "source")); + args.extend(strings(&[ + "-f", + "hls", + "-hls_time", + "4", + "-hls_list_size", + "0", + "-hls_playlist_type", + "event", + "-hls_allow_cache", + "1", + "-hls_flags", + "independent_segments", + "-hls_segment_type", + "mpegts", + "-hls_segment_filename", + "segment_%03d.ts", + "-start_number", + "0", + "playlist.m3u8", + ])); + args + }; + let attempt = self + .run_with_gpu_fallback( + &session_id, + false, + use_gpu && quality != "source", + build, + 1, + Some(&playlist), + ) + .await; + self.serve_session_playlist(&session_id, attempt, "VOD") + .await + } + + // ========================================== + // 3. LECTURE DES ENREGISTREMENTS + // ========================================== + + pub async fn recording(&self, state: &AppState, req: &Req, rest: &str) -> Resp { + if !req.is_get() { + return http::not_found(); + } + match segments(rest).as_slice() { + [id, "playlist.m3u8"] => self.recording_playlist(state, req, id).await, + [id, offset_key, segment] => self.recording_segment(id, offset_key, segment).await, + // Compat : playlist d'avant les offsets, encore en cache. + [id, segment] => { + self.recording_segment(id, &recording_offset_key(0), segment) + .await + } + _ => http::not_found(), + } + } + + async fn recording_segment(&self, id: &str, offset_key: &str, segment: &str) -> Resp { + if !is_valid_stream_id(id) + || parse_recording_offset_key(offset_key).is_none() + || segment.contains("..") + || segment.contains('/') + { + return http::text(400, "Invalid request"); + } + let session_id = format!("rec_{id}_{offset_key}"); + self.sessions.touch(&session_id); + serve_segment( + &self.sessions.base_dir.join(&session_id).join(segment), + 3600, + ) + .await + } + + async fn recording_playlist(&self, state: &AppState, req: &Req, id: &str) -> Resp { + if !is_valid_stream_id(id) { + return http::text(400, "Invalid request"); + } + let Some(recording) = state.db.get_recording_by_id(id) else { + return http::text(404, "Recording not found"); + }; + // Lisible seulement terminé ou en cours. + match recording.status.as_str() { + "scheduled" => return http::text(202, "Recording not yet started"), + "failed" => { + return http::text( + 422, + format!( + "Recording failed: {}", + recording.error_reason.as_deref().unwrap_or("unknown error") + ), + ); + } + "completed" | "recording" => {} + other => return http::text(500, format!("Invalid recording status: {other}")), + } + let Some(file) = recording.file_path.clone() else { + return http::text(500, "Recording file path not set"); + }; + if !Path::new(&file).exists() { + return http::text(500, "Physical recording file not found"); + } + + let start = parse_recording_start(req.query_param("start").as_deref()); + let offset_key = recording_offset_key(start); + let session_prefix = format!("rec_{id}_t"); + let session_id = format!("rec_{id}_{offset_key}"); + + // Capture en `-c copy` : presque toujours du H.264, directement + // lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p ; + // en copie, la segmentation va à la vitesse du disque. Seuls les codecs + // illisibles par le navigateur (HEVC, MPEG-2…) justifient un ré-encodage. + let codec = media_probe::video_codec(&file).await; + let can_copy = codec.as_deref() == Some("h264"); + + let build = |copy_video: bool, gpu: bool| -> Vec { + let gpu = !copy_video && gpu; + let mut args = strings(&["-hide_banner", "-loglevel", "warning"]); + if gpu { + args.extend(strings(&["-hwaccel", "cuda"])); + } + // -ss AVANT -i : recherche rapide par index ; la sortie repart de + // 0, le lecteur ré-ajoute l'offset. + if start > 0 { + args.extend(["-ss".to_string(), start.to_string()]); + } + args.extend(["-i".to_string(), file.clone()]); + if copy_video { + args.extend(strings(&["-c:v", "copy"])); + } else if gpu { + args.extend(strings(&[ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-tune", + "hq", + "-rc", + "cbr", + "-b:v", + "3000k", + "-maxrate", + "3500k", + "-bufsize", + "6000k", + "-g", + "48", + "-bf", + "2", + "-pix_fmt", + "yuv420p", + ])); + } else { + // veryfast/crf 20 : rendu identique, temps réel tenu en 1080p. + args.extend(strings(&[ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-maxrate", "12000k", + "-bufsize", "24000k", "-pix_fmt", "yuv420p", "-g", "48", "-threads", "0", + ])); + } + args.extend(audio_args(true)); + // `event` et non `vod` : hls.js relit la playlist et la zone + // navigable grandit avec l'encodage. + args.extend(strings(&[ + "-f", + "hls", + "-hls_time", + "4", + "-hls_list_size", + "0", + "-hls_playlist_type", + "event", + "-hls_allow_cache", + "1", + "-hls_flags", + "independent_segments", + "-hls_segment_type", + "mpegts", + "-hls_segment_filename", + "segment_%03d.ts", + "-start_number", + "0", + "playlist.m3u8", + ])); + args + }; + + // Trois segments d'avance avant de rendre la main : la marge qui + // manquait au démarrage. En copie, c'est une fraction de seconde. + let run = |copy_video: bool| { + if !self.sessions.contains(&session_id) { + println!( + "[Recording] {session_id} : {} (codec source : {})", + if copy_video { + "copie vidéo" + } else { + "ré-encodage" + }, + codec.as_deref().unwrap_or("inconnu") + ); + } + let build = &build; + let session_id = session_id.clone(); + let use_gpu = state.db.is_nvidia_gpu_enabled(); + async move { + if copy_video { + self.run_session(&session_id, false, &build(true, false), 3, None) + .await + } else { + self.run_with_gpu_fallback( + &session_id, + false, + use_gpu, + |gpu| build(false, gpu), + 3, + None, + ) + .await + } + } + }; + + let mut attempt = run(can_copy).await; + // Copie refusée (conteneur, horodatages) : repli sur le ré-encodage. + if !attempt.ready && can_copy { + println!( + "[Recording] {session_id} : copie vidéo refusée ({}) — repli sur le ré-encodage", + attempt.error.as_deref().unwrap_or("null") + ); + self.sessions.kill_session(&session_id); + attempt = run(false).await; + } + + let session = match attempt.session { + Some(s) if attempt.ready => s, + _ => { + self.sessions.kill_session(&session_id); + return http::text( + 502, + format!( + "Recording transcoder failed: {}", + attempt.error.as_deref().unwrap_or("null") + ), + ); + } + }; + session.touch(); + // Ménage des offsets abandonnés, une fois la nouvelle session lancée. + self.sessions + .kill_idle_siblings(&session_prefix, &session_id); + + match tokio::fs::read_to_string(session.playlist_path()).await { + Ok(playlist) => hls_headers(http::response( + 200, + rewrite_recording_playlist(&playlist, &offset_key), + )), + Err(_) => http::text(502, "Recording transcoder failed: playlist missing"), + } + } +} + +async fn open_live_upstream(client: &reqwest::Client, url: &str) -> Option { + client + .get(url) + .header("User-Agent", VLC_UA) + .header("Accept", "*/*") + .send() + .await + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_vod() { + assert_eq!(vod_container_extension(Some("mp4")), "mp4"); + assert_eq!(vod_container_extension(Some("MKV")), "mkv"); + assert_eq!(vod_container_extension(Some("avi")), "avi"); + assert_eq!(vod_container_extension(None), "mkv"); + assert_eq!(vod_container_extension(Some("")), "mkv"); + assert_eq!(vod_container_extension(Some("mp4/../x")), "mkv"); + assert_eq!(vod_container_extension(Some("mp4?a=b")), "mkv"); + assert_eq!(vod_container_extension(Some("toolongext")), "mkv"); + } + + #[test] + fn qualites() { + assert_eq!(sanitize_quality(Some("source")), "source"); + assert_eq!(sanitize_quality(Some("bogus")), "high"); + assert_eq!(sanitize_quality(None), "high"); + assert_eq!(live_video_args("source", true), vec!["-c:v", "copy"]); + assert!(live_video_args("high", true).contains(&"h264_nvenc".to_string())); + assert!(vod_video_args("low", false).contains(&"scale=-2:720".to_string())); + } + + #[test] + fn identifiants_de_flux() { + assert!(is_valid_stream_id("12345")); + assert!(is_valid_stream_id("abc_-1")); + assert!(!is_valid_stream_id("../x")); + assert!(!is_valid_stream_id("")); + } +} diff --git a/server/src/api/xtream_api.rs b/server/src/api/xtream_api.rs new file mode 100644 index 0000000..abb2a9e --- /dev/null +++ b/server/src/api/xtream_api.rs @@ -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"})) +} diff --git a/server/src/dart_time.rs b/server/src/dart_time.rs new file mode 100644 index 0000000..8cc8e66 --- /dev/null +++ b/server/src/dart_time.rs @@ -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 = 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) -> Self { + Self { + naive: truncate_micros(dt.naive_utc()), + utc: true, + } + } + + /// Instant local (`DateTime.toLocal()`). + pub fn local_from_utc(dt: DateTime) -> Self { + Self { + naive: truncate_micros(dt.with_timezone(&Local).naive_local()), + utc: false, + } + } + + /// `DateTime.tryParse`. + pub fn parse(raw: &str) -> Option { + let caps = PARSE_RE.captures(raw)?; + let num = |i: usize| -> Option { 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 { + 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) -> 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) -> 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()); + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..004cbc5 --- /dev/null +++ b/server/src/db.rs @@ -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, +} + +type DbResult = Result; + +fn parse_date(raw: Option) -> DartDateTime { + raw.as_deref() + .and_then(DartDateTime::parse) + .unwrap_or_else(DartDateTime::now_local) +} + +fn user_from_row(row: &Row) -> DbResult { + Ok(User { + id: row.get("id")?, + username: row.get("username")?, + is_admin: row.get::<_, Option>("is_admin")?.unwrap_or(0) == 1, + created_at: parse_date(row.get("created_at")?), + }) +} + +fn playlist_from_row(row: &Row) -> DbResult { + 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 { + let text = |name: &str| -> DbResult { + Ok(row.get::<_, Option>(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>("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> { + 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 = + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 = 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, + end: DateTime, + ) -> DbResult { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, end: DateTime) { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, + tolerance: Duration, + ) -> Option { + 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'])); + } +} diff --git a/server/src/http.rs b/server/src/http.rs new file mode 100644 index 0000000..26e06ff --- /dev/null +++ b/server/src/http.rs @@ -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, + /// Renseignés par le middleware d'authentification. + pub user: Option, + pub user_id: Option, +} + +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 { + url::form_urlencoded::parse(self.query.as_bytes()) + .into_owned() + .collect() + } + + pub fn query_param(&self, key: &str) -> Option { + 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) -> 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) -> 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 { + 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 { + 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"], "é"); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..1eb1423 --- /dev/null +++ b/server/src/main.rs @@ -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 = 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 = 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::>() + .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::(), + ) + .await + .expect("serveur HTTP"); +} + +async fn shutdown_on_signal( + scheduler: Arc, + sessions: Arc, +) { + 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" +} diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..44f8e0b --- /dev/null +++ b/server/src/models.rs @@ -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, + 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, + pub error_reason: Option, + pub created_at: DartDateTime, + pub updated_at: DartDateTime, +} + +impl Recording { + pub fn to_map(&self) -> Map { + 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 { + self.start_time.to_utc() + } + + pub fn end_utc(&self) -> chrono::DateTime { + self.end_time.to_utc() + } +} diff --git a/server/src/server.rs b/server/src/server.rs new file mode 100644 index 0000000..b7347f8 --- /dev/null +++ b/server/src/server.rs @@ -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>>>, +} + +impl RateLimiter { + pub fn new(limit: usize) -> Self { + let history: Arc>>> = 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, + ConnectInfo(remote): ConnectInfo, + 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/ 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 { + 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/.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() +} diff --git a/server/src/services/cleanup.rs b/server/src/services/cleanup.rs new file mode 100644 index 0000000..f29345b --- /dev/null +++ b/server/src/services/cleanup.rs @@ -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>, + 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) { + 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)) +} diff --git a/server/src/services/ffmpeg_sessions.rs b/server/src/services/ffmpeg_sessions.rs new file mode 100644 index 0000000..2bd8b2e --- /dev/null +++ b/server/src/services/ffmpeg_sessions.rs @@ -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 = 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>, +} + +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 { + 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::() + .trim() + .to_string() + } +} + +pub struct FfmpegSessionManager { + pub base_dir: PathBuf, + sessions: Mutex>>, +} + +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) { + 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> { + 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>, 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> { + 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) { + 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__t0`, + /// `rec__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 = 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 = 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> = + 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, + } + } +} diff --git a/server/src/services/gpu_fallback.rs b/server/src/services/gpu_fallback.rs new file mode 100644 index 0000000..47f844c --- /dev/null +++ b/server/src/services/gpu_fallback.rs @@ -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 = 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>, +} + +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))); + } +} diff --git a/server/src/services/logo_catalog.rs b/server/src/services/logo_catalog.rs new file mode 100644 index 0000000..48ee9c0 --- /dev/null +++ b/server/src/services/logo_catalog.rs @@ -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>>>; + +/// Catalogue de logos, partagé via `Arc`. +pub struct LogoCatalog { + inner: Arc, +} + +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, +} + +#[derive(Default)] +struct State { + listings: HashMap<&'static str, Arc>, + listings_in_flight: HashMap<&'static str, SharedListing>, + listing_failed_at: HashMap<&'static str, Instant>, + /// URL de téléchargement → octets PNG. + bytes: HashMap, +} + +struct Listing { + /// Clé normalisée → URL de téléchargement brute. + urls: HashMap, + 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, &'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 { + 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> { + 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> { + 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, 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 { + 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 = 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 { + 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, +) -> Option { + 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 { + parts.iter().filter(|t| *t != "live").cloned().collect() + }; + + let exact = |parts: &[String]| -> Option { + let slug = parts.join("-"); + (!parts.is_empty() && keys.contains(slug.as_str())).then_some(slug) + }; + + let longer_variant = |parts: &[String]| -> Option { + 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 { + let keys: Vec = 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>>) { + 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); + } +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs new file mode 100644 index 0000000..b074381 --- /dev/null +++ b/server/src/services/mod.rs @@ -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; diff --git a/server/src/services/recording_decision.rs b/server/src/services/recording_decision.rs new file mode 100644 index 0000000..577ba66 --- /dev/null +++ b/server/src/services/recording_decision.rs @@ -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, + start: DateTime, + end: DateTime, + 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, + end: DateTime, + 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, + end: DateTime, + 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, end: DateTime) -> 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 { + "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)); + } +} diff --git a/server/src/services/recording_scheduler.rs b/server/src/services/recording_scheduler.rs new file mode 100644 index 0000000..c3eaecd --- /dev/null +++ b/server/src/services/recording_scheduler.rs @@ -0,0 +1,1274 @@ +//! Planificateur des enregistrements TV : démarrage à l'heure, capture +//! FFmpeg (`-c copy`), relances après coupure amont, reprise après +//! redémarrage du serveur, fusion des parties, season passes et rotation +//! disque. + +use crate::dart_time::{DartDateTime, display_local, now_display}; +use crate::db::AppDatabase; +use crate::models::Recording; +use crate::services::recording_decision::{ + MAX_FFMPEG_ATTEMPTS, OrphanAction, PostExitAction, RecordingAction, capture_duration, + decide_orphan_action, decide_post_exit_action, decide_recording_action, ffmpeg_retry_delay, +}; +use crate::util::b64; +use crate::util::log_redactor::redact_url; +use crate::util::process::ManagedChild; +use chrono::Utc; +use futures::FutureExt; +use regex::Regex; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Write; +use std::panic::AssertUnwindSafe; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::AsyncReadExt; +use tokio::task::JoinHandle; + +/// Dossier où sont écrits les enregistrements et leurs logs. +pub fn recordings_dir_path() -> String { + std::env::var("RECORDINGS_DIR").unwrap_or_else(|_| "/app/recordings".to_string()) +} + +/// Binaire FFmpeg de la capture et de la fusion des parties. +pub fn resolve_ffmpeg_path() -> String { + if let Ok(path) = std::env::var("FFMPEG_PATH") + && !path.is_empty() + { + return path; + } + if cfg!(target_os = "linux") && Path::new("/usr/local/bin/ffmpeg").exists() { + return "/usr/local/bin/ffmpeg".to_string(); + } + "ffmpeg".to_string() +} + +fn env_int(name: &str, default: i64) -> i64 { + std::env::var(name) + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(default) +} + +/// Journal d'un enregistrement, tolérant aux erreurs d'écriture : la +/// capture reste prioritaire sur sa journalisation. +struct RecordingLog { + file: Mutex>, +} + +impl RecordingLog { + fn open(path: &Path, append: bool) -> Option { + let result = std::fs::OpenOptions::new() + .create(true) + .write(true) + .append(append) + .truncate(!append) + .open(path); + match result { + Ok(file) => Some(Self { + file: Mutex::new(Some(file)), + }), + Err(e) => { + println!( + "[RecordingScheduler] AVERTISSEMENT: log indisponible ({}): {e}", + path.display() + ); + None + } + } + } + + fn add(&self, data: &[u8]) { + let mut guard = self.file.lock().unwrap(); + if let Some(file) = guard.as_mut() + && file.write_all(data).is_err() + { + // Fichier cassé : on arrête d'écrire plutôt que de propager. + *guard = None; + } + } + + fn writeln(&self, line: &str) { + self.add(format!("{line}\n").as_bytes()); + } + + fn close(&self) { + if let Some(mut file) = self.file.lock().unwrap().take() { + let _ = file.flush(); + } + } +} + +struct ActiveState { + /// Fichiers additionnels écrits après une relance, dans l'ordre. + extra_parts: Vec, + /// Index du lancement FFmpeg en cours (0 = fichier principal). + attempt: u32, + /// Échecs consécutifs, remis à zéro quand une capture tient assez longtemps. + consecutive_failures: u32, + launched_at: Instant, + process: Option, +} + +struct ActiveRecording { + recording: Recording, + /// Fichier principal (celui exposé par l'API). + file_path: String, + log: Option, + state: Mutex, + /// Arrêt volontaire : la sortie de FFmpeg ne doit pas déclencher de relance. + stopping: AtomicBool, +} + +impl ActiveRecording { + fn log(&self, line: &str) { + if let Some(log) = &self.log { + log.writeln(line); + } + } +} + +pub struct RecordingScheduler { + db: Arc, + http: reqwest::Client, + active: Mutex>>, + is_running: AtomicBool, + timers: Mutex>>, + /// Enregistrements simultanés (MAX_CONCURRENT_RECORDINGS). + pub max_concurrent: usize, + /// Espace libre minimal (Mo) exigé pour démarrer une capture. + min_free_disk_mb: i64, + /// Quota du dossier d'enregistrements en Go (0 = rotation désactivée). + recordings_quota_gb: i64, + /// Enregistrements créés au plus par season pass et par scan. + season_pass_max_per_scan: i64, +} + +static UNSAFE_FILENAME_CHAR: LazyLock = + LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9_\-]").unwrap()); + +impl RecordingScheduler { + pub fn new(db: Arc, http: reqwest::Client) -> Self { + Self { + db, + http, + active: Mutex::new(HashMap::new()), + is_running: AtomicBool::new(false), + timers: Mutex::new(Vec::new()), + max_concurrent: env_int("MAX_CONCURRENT_RECORDINGS", 2).max(0) as usize, + min_free_disk_mb: env_int("MIN_FREE_DISK_MB", 500), + recordings_quota_gb: env_int("RECORDINGS_QUOTA_GB", 0), + season_pass_max_per_scan: env_int("SEASON_PASS_MAX_PER_SCAN", 10), + } + } + + /// Un enregistrement est-il activement capturé par un processus FFmpeg ? + pub fn is_capturing(&self, id: &str) -> bool { + self.active.lock().unwrap().contains_key(id) + } + + /// Relances FFmpeg de l'enregistrement actif [id] (`None` si inactif). + pub fn retry_count_of(&self, id: &str) -> Option { + let active = self.active.lock().unwrap().get(id).cloned()?; + let failures = active.state.lock().unwrap().consecutive_failures; + Some(failures) + } + + fn get_active(&self, id: &str) -> Option> { + self.active.lock().unwrap().get(id).cloned() + } + + fn is_current(&self, active: &Arc) -> bool { + self.get_active(&active.recording.id) + .is_some_and(|current| Arc::ptr_eq(¤t, active)) + } + + pub fn start(self: &Arc) { + println!( + "[RecordingScheduler] Démarrage du planificateur d'enregistrements TV (max simultanés: {})", + self.max_concurrent + ); + let mut timers = self.timers.lock().unwrap(); + + // Toutes les 10 secondes (démarrage quasi immédiat), premier passage tout de suite. + let this = self.clone(); + timers.push(tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + this.check_and_run_recordings().await; + } + })); + + // Season passes : un premier scan après 30 s (laisser le serveur + // démarrer), puis toutes les 4 heures. + let this = self.clone(); + timers.push(tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + this.check_season_passes().await; + })); + let this = self.clone(); + timers.push(tokio::spawn(async move { + let period = Duration::from_secs(4 * 3600); + let mut interval = + tokio::time::interval_at(tokio::time::Instant::now() + period, period); + loop { + interval.tick().await; + this.check_season_passes().await; + } + })); + } + + /// 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 8 secondes. + /// + /// 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. + pub async fn shutdown(self: &Arc) { + for timer in self.timers.lock().unwrap().drain(..) { + timer.abort(); + } + let ids: Vec = self.active.lock().unwrap().keys().cloned().collect(); + let closings: Vec> = ids + .iter() + .filter_map(|id| self.stop_active_recording(id, Some("Arrêt du serveur"))) + .collect(); + if !closings.is_empty() { + println!( + "[RecordingScheduler] Clôture de {} enregistrement(s)…", + closings.len() + ); + let _ = + tokio::time::timeout(Duration::from_secs(8), futures::future::join_all(closings)) + .await; + } + println!("[RecordingScheduler] Arrêté proprement"); + } + + async fn check_and_run_recordings(self: &Arc) { + if self.is_running.swap(true, Ordering::SeqCst) { + return; + } + let result = AssertUnwindSafe(self.run_tick()).catch_unwind().await; + if let Err(panic) = result { + println!("[RecordingScheduler] ERREUR CRITIQUE: {panic:?}"); + } + self.is_running.store(false, Ordering::SeqCst); + } + + async fn run_tick(self: &Arc) { + // Toujours comparer en UTC pour éviter les problèmes de fuseau horaire. + let now = Utc::now(); + + // Arrêter les enregistrements actifs dont l'heure de fin est passée. + let actives: Vec> = + self.active.lock().unwrap().values().cloned().collect(); + for active in actives { + if now > active.recording.end_utc() { + println!( + "[RecordingScheduler] Fin de l'enregistrement : {}", + active.recording.title + ); + self.stop_active_recording(&active.recording.id, None); + } + } + + // Lire la base APRÈS les arrêts : un instantané pris avant ferait + // passer l'enregistrement tout juste terminé pour un orphelin. + for recording in self.db.get_pending_recordings() { + // « recording » sans processus associé : le serveur a redémarré + // (ou l'enregistrement n'a jamais démarré). + if recording.status == "recording" && !self.is_capturing(&recording.id) { + self.recover_orphan(&recording, now).await; + continue; + } + if recording.status != "scheduled" { + continue; + } + let active_count = self.active.lock().unwrap().len(); + match decide_recording_action( + now, + recording.start_utc(), + recording.end_utc(), + active_count, + self.max_concurrent, + ) { + RecordingAction::Start => { + println!( + "[RecordingScheduler] *** DÉMARRAGE DE L'ENREGISTREMENT : {} ***", + recording.title + ); + self.start_recording(recording, false).await; + } + RecordingAction::Wait => { + // Capacité atteinte, fenêtre encore ouverte : on garde le + // statut "scheduled" et on réessaiera au prochain tick. + println!( + "[RecordingScheduler] Capacité max atteinte ({active_count}/{}), \"{}\" en attente.", + self.max_concurrent, recording.title + ); + } + RecordingAction::Fail => { + println!( + "[RecordingScheduler] Enregistrement \"{}\" manqué (fin dépassée).", + recording.title + ); + self.db.update_recording_status( + &recording.id, + "failed", + None, + Some("Heure de fin dépassée avant le démarrage"), + false, + ); + } + RecordingAction::None => {} + } + } + } + + /// Reprend (ou clôture) un enregistrement laissé au statut "recording" + /// par une interruption du serveur. + async fn recover_orphan(self: &Arc, recording: &Recording, now: chrono::DateTime) { + // Le statut a pu changer depuis la lecture : ne jamais requalifier un + // enregistrement sur une donnée périmée. + let Some(current) = self.db.get_recording_by_id(&recording.id) else { + return; + }; + if current.status != "recording" { + return; + } + match decide_orphan_action( + now, + current.end_utc(), + file_has_data(current.file_path.as_deref()), + ) { + OrphanAction::Resume => { + if self.active.lock().unwrap().len() >= self.max_concurrent { + return; // pas de slot libre : prochain tick + } + println!( + "[RecordingScheduler] Reprise après interruption : {}", + current.title + ); + self.start_recording(current, true).await; + } + OrphanAction::Finish => { + println!( + "[RecordingScheduler] Enregistrement interrompu conservé (partiel): {}", + current.title + ); + self.db.update_recording_status( + ¤t.id, + "completed", + None, + Some("Interruption du serveur pendant la capture : fichier partiel"), + false, + ); + } + OrphanAction::Fail => { + println!( + "[RecordingScheduler] Enregistrement orphelin sans fichier: {}", + current.id + ); + self.db.update_recording_status( + ¤t.id, + "failed", + None, + Some("Interruption inattendue du serveur"), + false, + ); + } + } + } + + /// Titre normalisé pour la correspondance : minuscules, espaces réduits. + fn normalize_title(title: &str) -> String { + title + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") + } + + fn title_matches(title: &str, show_title: &str, match_mode: &str) -> bool { + let t = Self::normalize_title(title); + let s = Self::normalize_title(show_title); + if s.is_empty() { + return false; + } + if match_mode == "contains" { + t.contains(&s) + } else { + t == s + } + } + + async fn check_season_passes(self: &Arc) { + let passes = self.db.get_all_season_passes(); + if passes.is_empty() { + return; + } + println!( + "[SeasonPass] Vérification de {} Season Pass(s)...", + passes.len() + ); + for pass in passes { + let show_title = pass + .get("show_title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if let Err(e) = self.scan_season_pass(&pass, &show_title).await { + // Jamais l'erreur brute : elle peut contenir l'URL amont avec + // les identifiants Xtream. + println!( + "[SeasonPass] Erreur pour le pass \"{show_title}\": {}", + redact_url(&e) + ); + } + } + } + + async fn scan_season_pass( + &self, + pass: &serde_json::Map, + show_title: &str, + ) -> Result<(), String> { + let field = |name: &str| { + pass.get(name) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let channel_id = field("channel_id"); + let stream_url = field("stream_url"); + let user_id = field("user_id"); + let match_mode = pass + .get("match_mode") + .and_then(Value::as_str) + .unwrap_or("contains") + .to_string(); + + // Playlist du PROPRIÉTAIRE du pass, résolue au moment du scan. + let playlists = self.db.get_playlists(&user_id); + let Some(playlist) = playlists.first() else { + println!( + "[SeasonPass] Aucune playlist pour le propriétaire du pass \"{show_title}\", scan ignoré" + ); + return Ok(()); + }; + + // Prochains programmes de la chaîne (`limit` = nombre de programmes). + let url = format!( + "{}/player_api.php?username={}&password={}&action=get_simple_data_table&stream_id={channel_id}&type=epg&limit=48", + playlist.server_url, playlist.username, playlist.password + ); + let request = self.http.get(&url).send(); + let response = tokio::time::timeout(Duration::from_secs(60), request) + .await + .map_err(|_| "TimeoutException after 0:01:00.000000".to_string())? + .map_err(|e| e.to_string())?; + if response.status().as_u16() != 200 { + return Ok(()); + } + let body = tokio::time::timeout(Duration::from_secs(60), response.bytes()) + .await + .map_err(|_| "TimeoutException after 0:01:00.000000".to_string())? + .map_err(|e| e.to_string())?; + let raw: Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?; + let listings = match &raw { + Value::Object(map) => map.get("epg_listings").and_then(Value::as_array).cloned(), + Value::Array(list) => Some(list.clone()), + _ => None, + } + .unwrap_or_default(); + + let mut created = 0; + for item in listings { + let raw_title = item.get("title").and_then(Value::as_str).unwrap_or(""); + let title = b64::decode_text(raw_title).unwrap_or_else(|| raw_title.to_string()); + if !Self::title_matches(&title, show_title, &match_mode) { + continue; + } + + let start_str = item.get("start").and_then(Value::as_str).unwrap_or(""); + let end_str = item + .get("stop") + .and_then(Value::as_str) + .or_else(|| item.get("end").and_then(Value::as_str)) + .unwrap_or(""); + if start_str.is_empty() || end_str.is_empty() { + continue; + } + let (Some(start), Some(end)) = + (DartDateTime::parse(start_str), DartDateTime::parse(end_str)) + else { + continue; + }; + let (start, end) = (start.to_utc(), end.to_utc()); + + // Programme déjà terminé : rien à créer. + if end < Utc::now() { + continue; + } + + // Déduplication : cet épisode est-il déjà planifié/enregistré ? + if let Some(existing) = + self.db + .find_recording_for_episode(&title, start, chrono::Duration::minutes(2)) + { + // 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.start_utc() != start || existing.end_utc() != end) + { + self.db.update_recording_window(&existing.id, start, end); + println!( + "[SeasonPass] \"{title}\" réaligné sur le nouvel horaire {}", + display_local(start) + ); + } + continue; + } + + if created >= self.season_pass_max_per_scan { + println!( + "[SeasonPass] Plafond de {} créations atteint pour \"{show_title}\" sur ce scan", + self.season_pass_max_per_scan + ); + break; + } + + self.db + .create_recording(&user_id, &channel_id, &stream_url, &title, start, end) + .map_err(|e| e.to_string())?; + created += 1; + println!( + "[SeasonPass] ✓ Planifié automatiquement: \"{title}\" le {}", + display_local(start) + ); + } + Ok(()) + } + + async fn start_recording(self: &Arc, recording: Recording, resume: bool) { + // Un (re)démarrage rend obsolète un éventuel motif d'échec précédent. + self.db + .update_recording_status(&recording.id, "recording", None, None, true); + + let id = recording.id.clone(); + if let Err(e) = self.try_start_recording(recording, resume).await { + // `io::Error` d'un spawn peut citer la commande : masquer. + println!( + "[RecordingScheduler] ERREUR dans _startRecording: {}", + redact_url(&e) + ); + self.db.update_recording_status( + &id, + "failed", + None, + Some(&format!("Erreur au lancement: {e}")), + false, + ); + if let Some(active) = self.active.lock().unwrap().remove(&id) + && let Some(log) = &active.log + { + log.close(); + } + } + } + + async fn try_start_recording( + self: &Arc, + recording: Recording, + resume: bool, + ) -> Result<(), String> { + let dir = PathBuf::from(recordings_dir_path()); + if !dir.exists() { + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + } + + // Libérer de la place si le quota est dépassé. + self.check_disk_space_and_rotate(&dir).await; + + // Refuser de démarrer sur un volume plein : mieux vaut un échec + // explicite immédiat qu'une capture qui meurt à mi-parcours. + if let Some(free) = free_disk_bytes(&dir) { + let min = self.min_free_disk_mb.max(0) as u64 * 1024 * 1024; + if free < min { + let free_mb = free / (1024 * 1024); + println!( + "[RecordingScheduler] Espace disque insuffisant ({free_mb} Mo libres, minimum {} Mo) : \"{}\" refusé", + self.min_free_disk_mb, recording.title + ); + self.db.update_recording_status( + &recording.id, + "failed", + None, + Some(&format!( + "Espace disque insuffisant ({free_mb} Mo libres, minimum {} Mo)", + self.min_free_disk_mb + )), + false, + ); + return Ok(()); + } + } + + let file_path = dir + .join(file_name_for(&recording)) + .to_string_lossy() + .into_owned(); + let log_path = Path::new(&file_path).with_extension("log"); + // Une reprise complète le log existant au lieu de l'écraser. + let log = RecordingLog::open(&log_path, resume); + + let mut state = ActiveState { + extra_parts: Vec::new(), + attempt: 0, + consecutive_failures: 0, + launched_at: Instant::now(), + process: None, + }; + if resume { + // Le fichier principal (et d'éventuelles parties) survivent au + // redémarrage : on repart sur une nouvelle partie. + state.extra_parts = existing_parts(&file_path); + state.attempt = if file_has_data(Some(&file_path)) || !state.extra_parts.is_empty() { + state.extra_parts.len() as u32 + 1 + } else { + 0 + }; + } + let active = Arc::new(ActiveRecording { + file_path: file_path.clone(), + log, + state: Mutex::new(state), + stopping: AtomicBool::new(false), + recording, + }); + if resume { + active.log(&format!( + "\n[{}] Reprise après interruption du serveur", + now_display() + )); + } else { + active.log(&format!( + "[{}] Démarrage: {}", + now_display(), + active.recording.title + )); + active.log(&format!("Destination: {file_path}")); + } + + self.active + .lock() + .unwrap() + .insert(active.recording.id.clone(), active.clone()); + + // Chemin du fichier en base. + self.db.update_recording_status( + &active.recording.id, + "recording", + Some(&file_path), + None, + false, + ); + + self.launch_ffmpeg(&active).map_err(|e| e.to_string()) + } + + /// Lance (ou relance) FFmpeg pour un enregistrement actif. + fn launch_ffmpeg(self: &Arc, active: &Arc) -> std::io::Result<()> { + let recording = &active.recording; + let output = { + let mut state = active.state.lock().unwrap(); + let output = if state.attempt == 0 { + active.file_path.clone() + } else { + part_path(&active.file_path, state.attempt) + }; + if state.attempt > 0 && !state.extra_parts.contains(&output) { + state.extra_parts.push(output.clone()); + } + output + }; + + // URL relative → absolue : le serveur écoute sur 8089 dans le conteneur. + let mut stream_url = recording.stream_url.clone(); + if stream_url.starts_with('/') { + stream_url = format!("http://localhost:8089{stream_url}"); + } + + let duration = capture_duration(Utc::now(), recording.end_utc()); + let args: Vec = [ + "-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 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", + ] + .iter() + .map(|s| s.to_string()) + .chain([ + stream_url.clone(), + "-c".into(), + "copy".into(), + "-t".into(), + duration.num_seconds().to_string(), + output, + ]) + .collect(); + + let ffmpeg = resolve_ffmpeg_path(); + // Le log est exposé via l'API : jamais d'identifiants dedans. + let redacted = args + .iter() + .map(|a| redact_url(a)) + .collect::>() + .join(" "); + active.log(&format!("URL: {}", redact_url(&stream_url))); + active.log(&format!("Commande: {ffmpeg} {redacted}\n")); + println!("[RecordingScheduler] Exécution: {ffmpeg} {redacted}"); + + let mut child = tokio::process::Command::new(&ffmpeg) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let process = ManagedChild::spawn(child); + { + let mut state = active.state.lock().unwrap(); + state.process = Some(process.clone()); + state.launched_at = Instant::now(); + } + + // stdout/stderr vers le log ; on attend la fin des deux flux avant de + // traiter la sortie, sinon les derniers morceaux seraient perdus. + let pipes: Vec> = [ + stdout.map(|s| Box::new(s) as Box), + stderr.map(|s| Box::new(s) as Box), + ] + .into_iter() + .flatten() + .map(|mut stream| { + let active = active.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + while let Ok(n) = stream.read(&mut buf).await { + if n == 0 { + break; + } + if let Some(log) = &active.log { + log.add(&buf[..n]); + } + } + }) + }) + .collect(); + + let this = self.clone(); + let active = active.clone(); + tokio::spawn(async move { + let code = process.wait().await; + for pipe in pipes { + let _ = pipe.await; + } + let result = AssertUnwindSafe(this.on_ffmpeg_exit(active, code)) + .catch_unwind() + .await; + if let Err(panic) = result { + println!("[RecordingScheduler] ERREUR à la sortie de FFmpeg: {panic:?}"); + } + }); + Ok(()) + } + + /// Suite à donner quand un processus FFmpeg se termine. + async fn on_ffmpeg_exit(self: Arc, active: Arc, exit_code: i32) { + let recording = &active.recording; + + // Arrêt volontaire, ou enregistrement repris ailleurs : la clôture est + // gérée par celui qui a demandé l'arrêt. + if active.stopping.load(Ordering::SeqCst) || !self.is_current(&active) { + return; + } + + // Une capture qui a tenu longtemps repart d'un quota de relances neuf. + let (lasted, has_data) = { + let state = active.state.lock().unwrap(); + ( + state.launched_at.elapsed(), + active_has_data(&active.file_path, &state.extra_parts), + ) + }; + let consecutive = { + let mut state = active.state.lock().unwrap(); + if lasted > Duration::from_secs(120) && has_data { + state.consecutive_failures = 0; + } + state.consecutive_failures + }; + + match decide_post_exit_action( + Utc::now(), + recording.end_utc(), + exit_code, + consecutive, + has_data, + ) { + PostExitAction::Retry => { + let failures = { + let mut state = active.state.lock().unwrap(); + state.attempt += 1; + state.consecutive_failures += 1; + state.consecutive_failures + }; + let delay = ffmpeg_retry_delay(failures); + let message = format!( + "FFmpeg s'est arrêté (code {exit_code}) après {}s, avant la fin prévue — relance dans {}s (tentative {failures}/{})", + lasted.as_secs(), + delay.as_secs(), + MAX_FFMPEG_ATTEMPTS - 1 + ); + println!("[RecordingScheduler] {}: {message}", recording.title); + active.log(&format!("\n[{}] {message}", now_display())); + // Laisser la source respirer : sur les comptes limités en + // connexions simultanées, rouvrir immédiatement se fait refuser. + tokio::time::sleep(delay).await; + if active.stopping.load(Ordering::SeqCst) || !self.is_current(&active) { + return; + } + if let Err(e) = self.launch_ffmpeg(&active) { + println!( + "[RecordingScheduler] Relance impossible: {}", + redact_url(&e.to_string()) + ); + self.active.lock().unwrap().remove(&recording.id); + self.finalize( + &active, + "failed", + Some(&format!("Relance de FFmpeg impossible: {e}")), + ) + .await; + } + } + PostExitAction::Complete => { + println!( + "[RecordingScheduler] Enregistrement terminé: {}", + recording.title + ); + self.active.lock().unwrap().remove(&recording.id); + self.finalize(&active, "completed", None).await; + } + PostExitAction::Fail => { + println!( + "[RecordingScheduler] Erreur FFmpeg (code: {exit_code}) pour {}", + recording.title + ); + self.active.lock().unwrap().remove(&recording.id); + self.finalize( + &active, + "failed", + Some(&format!("Erreur FFmpeg code {exit_code}. Voir logs.")), + ) + .await; + } + } + } + + /// Clôture : fusion des parties, fermeture du log, statut. + async fn finalize(&self, active: &ActiveRecording, status: &str, error_reason: Option<&str>) { + if let Err(e) = self.merge_parts(active).await { + println!("[RecordingScheduler] Fusion des parties impossible: {e}"); + } + active.log(&format!( + "\n[{}] Enregistrement clôturé avec le statut \"{status}\"", + now_display() + )); + if let Some(log) = &active.log { + log.close(); + } + self.db.update_recording_status( + &active.recording.id, + status, + Some(&active.file_path), + error_reason, + false, + ); + } + + /// Recolle les parties issues des relances dans le fichier principal. + /// + /// Les parties viennent de la même source en `-c copy` : le demuxer + /// `concat` les rassemble sans réencodage. En cas d'échec, fichier + /// principal et parties sont conservés tels quels. + async fn merge_parts(&self, active: &ActiveRecording) -> std::io::Result<()> { + let parts: Vec = { + let state = active.state.lock().unwrap(); + state + .extra_parts + .iter() + .filter(|p| file_has_data(Some(p))) + .cloned() + .collect() + }; + if parts.is_empty() { + return Ok(()); + } + let mut segments = Vec::new(); + if file_has_data(Some(&active.file_path)) { + segments.push(active.file_path.clone()); + } + segments.extend(parts.iter().cloned()); + if segments.len() < 2 { + // Fichier principal vide : la première partie le remplace. + return tokio::fs::rename(&segments[0], &active.file_path).await; + } + + let list_file = format!("{}.parts.txt", active.file_path); + let list = segments + .iter() + .map(|s| format!("file '{}'", s.replace('\'', r"'\''"))) + .collect::>() + .join("\n"); + tokio::fs::write(&list_file, list).await?; + let merged = format!("{}.merged.mkv", active.file_path); + + active.log(&format!( + "\n[{}] Fusion de {} parties…", + now_display(), + segments.len() + )); + let result = tokio::process::Command::new(resolve_ffmpeg_path()) + .args([ + "-hide_banner", + "-nostats", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + &list_file, + "-c", + "copy", + &merged, + ]) + .stdin(Stdio::null()) + .output() + .await; + delete_quietly(&list_file); + + let code = result + .as_ref() + .map(|o| o.status.code().unwrap_or(-1)) + .unwrap_or(-1); + if code != 0 || !file_has_data(Some(&merged)) { + active.log(&format!( + "Fusion échouée (code {code}) : les parties sont conservées séparément." + )); + delete_quietly(&merged); + return Ok(()); + } + tokio::fs::rename(&merged, &active.file_path).await?; + for part in parts { + delete_quietly(&part); + } + active.log("Fusion terminée."); + Ok(()) + } + + /// Arrête un enregistrement en cours (appelé depuis l'API). + pub fn stop_recording(self: &Arc, id: &str) -> bool { + let Some(active) = self.get_active(id) else { + return false; // pas d'enregistrement actif avec cet ID + }; + println!( + "[RecordingScheduler] Arrêt demandé pour: {}", + active.recording.title + ); + self.stop_active_recording(id, None); + true + } + + /// Arrête l'enregistrement actif [id] et renvoie la tâche de clôture + /// (fusion + statut), ou `None` si aucun n'est actif. + fn stop_active_recording( + self: &Arc, + id: &str, + reason: Option<&str>, + ) -> Option> { + let active = self.active.lock().unwrap().remove(id)?; + active.stopping.store(true, Ordering::SeqCst); + if let Some(reason) = reason { + println!( + "[RecordingScheduler] Arrêt: {reason} ({})", + active.recording.title + ); + } + active.log(&format!( + "\n[{}] Arrêt: {}", + now_display(), + reason.unwrap_or("fin de la fenêtre programmée") + )); + let process = active.state.lock().unwrap().process.clone(); + if let Some(process) = &process { + process.terminate(); + } + self.db + .update_recording_status(id, "completed", None, None, false); + + let this = self.clone(); + Some(tokio::spawn(async move { + // Attendre la fin effective de FFmpeg avant de clôturer. + if let Some(process) = process + && tokio::time::timeout(Duration::from_secs(15), process.wait()) + .await + .is_err() + { + process.kill(); + } + this.finalize(&active, "completed", None).await; + })) + } + + /// Fichiers d'un enregistrement : principal, log, parties des relances. + pub fn files_for(file_path: &str) -> Vec { + let mut paths = vec![ + file_path.to_string(), + Path::new(file_path) + .with_extension("log") + .to_string_lossy() + .into_owned(), + ]; + for attempt in 1.. { + let part = part_path(file_path, attempt); + if !Path::new(&part).exists() { + break; + } + paths.push(part); + } + paths + } + + /// Supprime les fichiers d'un enregistrement (API et rotation disque). + pub fn delete_recording_files(file_path: &str) { + for path in Self::files_for(file_path) { + delete_quietly(&path); + } + } + + /// Rotation par quota d'octets (RECORDINGS_QUOTA_GB, 0 = désactivée). + /// + /// Ne supprime que des enregistrements TERMINÉS connus de la base, du plus + /// ancien au plus récent, fichiers et ligne ensemble, jamais un actif. + async fn check_disk_space_and_rotate(&self, dir: &Path) { + if self.recordings_quota_gb <= 0 { + return; + } + let quota = self.recordings_quota_gb as u64 * 1024 * 1024 * 1024; + let mut total: u64 = std::fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .filter_map(|e| e.metadata().ok()) + .filter(|m| m.is_file()) + .map(|m| m.len()) + .sum() + }) + .unwrap_or(0); + if total <= quota { + return; + } + println!( + "[RecordingScheduler] Quota disque dépassé ({} Mo > {} Go) : rotation des enregistrements terminés les plus anciens", + total / (1024 * 1024), + self.recordings_quota_gb + ); + for old in self.db.get_finished_recordings_oldest_first() { + if total <= quota { + break; + } + let Some(path) = old.file_path.clone() else { + continue; + }; + // Jamais un enregistrement encore capturé (statut périmé en base). + if self.is_capturing(&old.id) { + continue; + } + let freed: u64 = Self::files_for(&path) + .iter() + .filter_map(|f| std::fs::metadata(f).ok()) + .map(|m| m.len()) + .sum(); + Self::delete_recording_files(&path); + self.db.delete_recording(&old.id); + total = total.saturating_sub(freed); + println!( + "[RecordingScheduler] Rotation : \"{}\" supprimé ({} Mo libérés)", + old.title, + freed / (1024 * 1024) + ); + } + } +} + +/// Nom de fichier unique et sûr. Le fragment d'id garantit l'unicité : deux +/// utilisateurs enregistrant le même programme s'écrasaient (FFmpeg -y). +fn file_name_for(recording: &Recording) -> String { + // Un caractère hors BMP comptait double (UTF-16) dans l'ancien serveur : + // même nom de fichier, pour que la reprise retrouve ses parties. + let mut safe_title = String::new(); + for c in recording.title.chars() { + if UNSAFE_FILENAME_CHAR.is_match(c.encode_utf8(&mut [0; 4])) { + safe_title.push_str(if (c as u32) > 0xFFFF { "__" } else { "_" }); + } else { + safe_title.push(c); + } + } + let date = recording.start_time.as_utc().to_iso().replace(':', ""); + let date = date.split('.').next().unwrap_or(""); + let fragment: String = recording.id.chars().take(8).collect(); + format!("{safe_title}_{date}_{fragment}.mkv") +} + +/// Chemin de la n-ième partie (la partie 1 étant le fichier principal). +fn part_path(file_path: &str, attempt: u32) -> String { + let without_ext = Path::new(file_path).with_extension(""); + format!("{}.part{}.mkv", without_ext.to_string_lossy(), attempt + 1) +} + +/// Parties déjà écrites sur disque pour cet enregistrement, dans l'ordre. +fn existing_parts(file_path: &str) -> Vec { + let mut parts = Vec::new(); + for attempt in 1.. { + let path = part_path(file_path, attempt); + if !file_has_data(Some(&path)) { + break; + } + parts.push(path); + } + parts +} + +fn active_has_data(file_path: &str, extra_parts: &[String]) -> bool { + file_has_data(Some(file_path)) || extra_parts.iter().any(|p| file_has_data(Some(p))) +} + +fn file_has_data(path: Option<&str>) -> bool { + path.and_then(|p| std::fs::metadata(p).ok()) + .is_some_and(|m| m.is_file() && m.len() > 0) +} + +fn delete_quietly(path: &str) { + let _ = std::fs::remove_file(path); +} + +/// Espace libre (octets) sur le volume qui porte [path]. +#[cfg(unix)] +fn free_disk_bytes(path: &Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; + (rc == 0).then(|| stat.f_bavail as u64 * stat.f_frsize as u64) +} + +#[cfg(not(unix))] +fn free_disk_bytes(_path: &Path) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn recording(title: &str) -> Recording { + let start = DartDateTime::parse("2026-08-11T18:00:00Z").unwrap(); + Recording { + id: "0123456789abcdef".into(), + user_id: "u".into(), + channel_id: "1".into(), + stream_url: "/api/live/1.ts".into(), + title: title.into(), + start_time: start, + end_time: start, + status: "scheduled".into(), + file_path: None, + error_reason: None, + created_at: start, + updated_at: start, + } + } + + #[test] + fn nom_de_fichier_identique_a_l_ancien_serveur() { + assert_eq!( + file_name_for(&recording("Le 20h: édition spéciale")), + "Le_20h___dition_sp_ciale_2026-08-11T180000_01234567.mkv" + ); + assert_eq!( + file_name_for(&recording("Foot ⚽")), + "Foot___2026-08-11T180000_01234567.mkv" + ); + assert_eq!( + file_name_for(&recording("Fun 🎉")), + "Fun____2026-08-11T180000_01234567.mkv" + ); + } + + #[test] + fn chemins_des_parties() { + assert_eq!(part_path("/rec/a_b.mkv", 1), "/rec/a_b.part2.mkv"); + assert_eq!(part_path("/rec/a_b.mkv", 3), "/rec/a_b.part4.mkv"); + } + + #[test] + fn correspondance_des_titres() { + assert!(RecordingScheduler::title_matches( + "Journal de 20h", + "journal de 20h", + "exact" + )); + assert!(!RecordingScheduler::title_matches( + "Le Journal", + "journal", + "exact" + )); + assert!(RecordingScheduler::title_matches( + "Le Journal", + "journal", + "contains" + )); + assert!(!RecordingScheduler::title_matches( + "Le Journal", + " ", + "contains" + )); + } +} diff --git a/server/src/services/upstream_slots.rs b/server/src/services/upstream_slots.rs new file mode 100644 index 0000000..29382e3 --- /dev/null +++ b/server/src/services/upstream_slots.rs @@ -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; +type IsActive = Box 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, +} + +impl Slot { + fn active(&self) -> bool { + self.is_active.as_ref().is_some_and(|f| f()) + } +} + +#[derive(Default)] +struct Inner { + slots: HashMap, + next: u64, +} + +#[derive(Default)] +pub struct UpstreamSlots { + inner: Mutex, +} + +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, + ) -> 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) { + 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 { + 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 { + 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 BoxFuture<'static, Option> + 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>>, + refreshing: Arc>>, +} + +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>>, 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::>(); + 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 + ); + } +} diff --git a/server/src/services/xmltv.rs b/server/src/services/xmltv.rs new file mode 100644 index 0000000..bdafd96 --- /dev/null +++ b/server/src/services/xmltv.rs @@ -0,0 +1,1634 @@ +//! 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 (`quick-xml`, événement par événement) : 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. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, NaiveDate, TimeDelta, Utc}; +use futures::future::{BoxFuture, FutureExt, Shared}; +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use regex::Regex; + +use crate::dart_time::utc_iso; +use crate::util::log_redactor::redact_url; + +/// Index : clé de chaîne normalisée → programmes triés par heure de début. +/// +/// Les clés d'alias (nom affiché, clé souple) partagent la même liste via +/// l'`Arc` : pas de copie des programmes par alias. +pub type XmltvIndex = HashMap>>; + +/// Un programme du guide, horaires en UTC. +#[derive(Clone, Debug, PartialEq)] +pub struct XmltvProgramme { + pub title: String, + pub description: String, + pub start: DateTime, + pub stop: DateTime, +} + +impl XmltvProgramme { + /// Forme JSON servie par l'API EPG (mêmes clés que le guide du panneau). + pub fn to_json(&self, channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "title": self.title, + "description": self.description, + "start": utc_iso(self.start), + "end": utc_iso(self.stop), + "channel_id": channel_id, + }) + } +} + +/// Réglages du service. +#[derive(Clone, Debug)] +pub struct XmltvConfig { + /// Dumps XMLTV à agréger, dans l'ordre de priorité décroissante. + pub source_urls: Vec, + + /// Fréquence de rafraîchissement de l'index. + pub refresh_interval: Duration, + + /// 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. + pub retention: Duration, + + /// 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. + pub horizon: Duration, + + /// 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. + pub retry_backoff: Duration, + + /// Plafond de téléchargement d'un dump. Le guide est facultatif : mieux + /// vaut abandonner et servir le panneau que faire attendre l'utilisateur. + pub download_timeout: Duration, +} + +impl XmltvConfig { + /// Réglages par défaut : rafraîchissement 6 h, rétention 6 h, horizon + /// 48 h, recul 15 min après échec, téléchargement plafonné à 5 min. + pub fn new(source_urls: Vec) -> Self { + Self { + source_urls, + refresh_interval: Duration::from_secs(6 * 3600), + retention: Duration::from_secs(6 * 3600), + horizon: Duration::from_secs(48 * 3600), + retry_backoff: Duration::from_secs(15 * 60), + download_timeout: Duration::from_secs(5 * 60), + } + } +} + +type Logger = Arc; +type RefreshFuture = Shared>; + +/// Service d'index XMLTV, partagé via `Arc`. +pub struct XmltvEpgService { + inner: Arc, +} + +struct Inner { + config: XmltvConfig, + client: reqwest::Client, + state: Mutex, + log: Logger, +} + +#[derive(Default)] +struct State { + index: Arc, + indexed_at: Option, + failed_at: Option, + /// Rafraîchissement en cours, partagé par les appels concurrents. + refresh_in_flight: Option, +} + +impl Inner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// Libère la place du rafraîchissement en cours quand il se termine — même +/// s'il panique, sinon plus aucun rafraîchissement ne serait jamais relancé. +struct RefreshGuard(Arc); + +impl Drop for RefreshGuard { + fn drop(&mut self) { + self.0.lock().refresh_in_flight = None; + } +} + +/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `… +static COUNTRY_PREFIX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^{W}*[\|\[\(]?{W}*([A-Za-z]{{2,3}}){W}*[\|\]\)]?{W}*[-:|]{W}*|^{W}*[\|\[\(]{W}*([A-Za-z]{{2,3}}){W}*[\|\]\)]{W}*", + W = JS_SPACE_CLASS + )) + .unwrap() +}); + +/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la +/// chaîne elle-même. +static QUALITY_SUFFIX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"(?i)({W}+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\)){W}*$", + W = JS_SPACE_CLASS + )) + .unwrap() +}); + +/// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni +/// chiffre, ni ponctuation utile. +static DECORATIVE_SYMBOLS: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"[^\p{{L}}\p{{N}}{JS_SPACE_CHARS}+.&\-()]")).unwrap()); + +/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là +/// où les dumps écrivent `France.3.-.Alpes.fr`. +/// +/// Le `\b` final de l'expression Dart (`^F\s?([2-5])\b`) est une limite de +/// mot ASCII : il est vérifié à la main dans [`XmltvEpgService::clean_channel_name`], +/// le `\b` de `regex` étant Unicode. +static FRANCE_ABBREV: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?i)^F{JS_SPACE_CLASS}?([2-5])")).unwrap()); + +/// Caractères de `\s` en JavaScript/Dart (diffère du `\s` Unicode de +/// `regex` : U+FEFF en plus, U+0085 en moins). +const JS_SPACE_CHARS: &str = + r"\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}"; +const JS_SPACE_CLASS: &str = r"[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]"; + +fn is_js_space(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}' + ..='\u{200A}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) +} + +/// `String.trim()` de Dart : espaces Unicode plus le BOM. +fn dart_trim(s: &str) -> &str { + s.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}') +} + +/// Jetons sans valeur distinctive pour reconnaître une chaîne. +const LOOSE_NOISE: &[&str] = &["channel", "tv", "hd", "fhd", "uhd", "sd", "4k", "hevc"]; + +/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`). +const COUNTRY_CODES: &[&str] = &[ + "fr", "be", "ch", "lu", "mc", "ca", "uk", "us", "es", "it", "de", "pt", "nl", +]; + +fn fold_accent(c: char) -> char { + match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'ì' | 'í' | 'î' | 'ï' => 'i', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o', + 'ù' | 'ú' | 'û' | 'ü' => 'u', + 'ç' => 'c', + 'ñ' => 'n', + other => other, + } +} + +fn loose_index_key(loose: &str) -> String { + if loose.is_empty() { + String::new() + } else { + format!("~{loose}") + } +} + +/// Positions des « +1 » (`\+\s*1(?![0-9])` côté Dart, écrit à la main faute +/// de look-ahead dans `regex`). Correspondances disjointes, de gauche à +/// droite, comme `replaceAll`. +fn plus_one_spans(s: &str) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + let mut i = 0; + while i < s.len() { + if s[i..].starts_with('+') { + let mut j = i + 1; + while let Some(c) = s[j..].chars().next() { + if !is_js_space(c) { + break; + } + j += c.len_utf8(); + } + if s[j..].starts_with('1') { + let end = j + 1; + if !matches!(s[end..].chars().next(), Some('0'..='9')) { + spans.push((i, end)); + i = end; + continue; + } + } + } + i += s[i..].chars().next().map_or(1, char::len_utf8); + } + spans +} + +fn replace_plus_one(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut last = 0; + for (start, end) in plus_one_spans(s) { + out.push_str(&s[last..start]); + out.push(' '); + last = end; + } + out.push_str(&s[last..]); + out +} + +impl XmltvEpgService { + pub fn new(config: XmltvConfig, client: reqwest::Client) -> Self { + Self::with_logger(config, client, Arc::new(|line: &str| println!("{line}"))) + } + + fn with_logger(config: XmltvConfig, client: reqwest::Client, log: Logger) -> Self { + Self { + inner: Arc::new(Inner { + config, + client, + state: Mutex::new(State::default()), + log, + }), + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn has_data(&self) -> bool { + !self.inner.lock().index.is_empty() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn channel_count(&self) -> usize { + self.inner.lock().index.len() + } + + /// Programmes d'une chaîne, ou liste vide si inconnue. + /// + /// `channel_id` est l'`epg_channel_id` renvoyé par Xtream ; `display_name` + /// est le nom de la chaîne, utilisé en second recours car les dumps publics + /// construisent souvent leur identifiant à partir du nom affiché. + pub async fn programmes_for( + &self, + channel_id: Option<&str>, + display_name: Option<&str>, + ) -> Vec { + self.ensure_fresh().await; + let index = Arc::clone(&self.inner.lock().index); + + if let Some(direct) = lookup(&index, channel_id, display_name) { + return direct.as_ref().clone(); + } + + // 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 let Some(name) = display_name + && !plus_one_spans(name).is_empty() + { + let base = replace_plus_one(name); + let hour = TimeDelta::hours(1); + return lookup(&index, None, Some(&base)) + .map(|hits| { + hits.iter() + .map(|p| XmltvProgramme { + title: p.title.clone(), + description: p.description.clone(), + start: p.start + hour, + stop: p.stop + hour, + }) + .collect() + }) + .unwrap_or_default(); + } + Vec::new() + } + + /// 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. + pub fn loose_key(raw: Option<&str>) -> String { + let Some(raw) = raw else { + return String::new(); + }; + let folded: String = raw.to_lowercase().chars().map(fold_accent).collect(); + let mut tokens: Vec<&str> = folded + .split(|c: char| !matches!(c, 'a'..='z' | '0'..='9')) + .filter(|t| !t.is_empty()) + .collect(); + if tokens.len() > 1 && tokens.last().is_some_and(|t| COUNTRY_CODES.contains(t)) { + tokens.pop(); + } + tokens.retain(|t| !LOOSE_NOISE.contains(t)); + tokens.concat() + } + + /// 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. + pub fn name_keys(name: &str) -> Vec { + let mut candidates = vec![Self::normalize_key(Some(name))]; + let (cleaned, country) = Self::clean_channel_name(name); + let base = Self::normalize_key(Some(&cleaned)); + if !base.is_empty() { + let suffixed = format!("{base}{}", country.as_deref().unwrap_or("fr")); + candidates.push(base); + candidates.push(suffixed); + } + // Dédoublonnage en gardant l'ordre (le `toSet()` de Dart est ordonné). + let mut seen = HashSet::new(); + candidates + .into_iter() + .filter(|k| !k.is_empty() && seen.insert(k.clone())) + .collect() + } + + /// Nom de chaîne débarrassé du préfixe pays, des symboles et des + /// marqueurs de qualité ; second élément = code pays du préfixe, s'il y + /// en a. + pub fn clean_channel_name(raw: &str) -> (String, Option) { + let mut cleaned: &str = raw; + let mut country = None; + if let Some(caps) = COUNTRY_PREFIX.captures(raw) { + country = caps + .get(1) + .or_else(|| caps.get(2)) + .map(|m| m.as_str().to_lowercase()); + cleaned = &raw[caps.get(0).map_or(0, |m| m.end())..]; + } + // Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni + // chiffre, ni ponctuation utile. + let mut cleaned = dart_trim(&DECORATIVE_SYMBOLS.replace_all(cleaned, " ")).to_string(); + loop { + let previous = cleaned.clone(); + cleaned = dart_trim(&QUALITY_SUFFIX.replacen(&cleaned, 1, "")).to_string(); + if cleaned == previous || cleaned.is_empty() { + break; + } + } + if let Some(caps) = FRANCE_ABBREV.captures(&cleaned) { + let whole = caps.get(0).unwrap(); + // Limite de mot ASCII après le chiffre (le `\b` de Dart). + let boundary = !matches!( + cleaned[whole.end()..].chars().next(), + Some('a'..='z' | 'A'..='Z' | '0'..='9' | '_') + ); + if boundary { + cleaned = format!("France {}{}", &caps[1], &cleaned[whole.end()..]); + } + } + (cleaned, country) + } + + /// Recharge l'index s'il est absent ou périmé. Les appels concurrents + /// partagent le même téléchargement. + /// + /// Le rafraîchissement tourne dans sa propre tâche : comme une `Future` + /// Dart, il va au bout même si l'appelant abandonne (requête HTTP coupée). + pub async fn ensure_fresh(&self) { + // Sans source, `_refresh` Dart ne faisait rien : inutile de lancer une + // tâche. + if self.inner.config.source_urls.is_empty() { + return; + } + let pending = { + let mut state = self.inner.lock(); + if state + .indexed_at + .is_some_and(|at| at.elapsed() < self.inner.config.refresh_interval) + { + return; + } + if state + .failed_at + .is_some_and(|at| at.elapsed() < self.inner.config.retry_backoff) + { + return; + } + match &state.refresh_in_flight { + Some(pending) => pending.clone(), + None => { + let inner = Arc::clone(&self.inner); + let task = tokio::spawn(async move { + let _guard = RefreshGuard(Arc::clone(&inner)); + refresh(&inner).await; + }); + let pending = async move { + let _ = task.await; + } + .boxed() + .shared(); + state.refresh_in_flight = Some(pending.clone()); + pending + } + } + }; + pending.await; + } + + /// Exposé pour les tests. Un XML illisible (où Dart levait une exception) + /// donne un index vide. + #[cfg_attr(not(test), allow(dead_code))] + pub fn parse_for_test(&self, xml: &str) -> XmltvIndex { + parse(xml, self.inner.config.retention, self.inner.config.horizon).unwrap_or_default() + } + + /// 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. + pub fn normalize_key(raw: Option<&str>) -> String { + let Some(raw) = raw else { + return String::new(); + }; + raw.to_lowercase() + .chars() + .map(fold_accent) + .filter(|c| matches!(c, 'a'..='z' | '0'..='9')) + .collect() + } + + /// « 20260811200000 +0200 » → instant UTC. + /// + /// Reproduit `DateTime.tryParse` de Dart : 14 chiffres ASCII exigés, les + /// valeurs hors bornes (mois 13, jour 32…) étant reportées sur l'unité + /// supérieure plutôt que rejetées. Longueurs et positions comptées en + /// unités UTF-16, comme en Dart. + pub fn parse_xmltv_date(raw: Option<&str>) -> Option> { + let raw = raw?; + let units: Vec = raw.encode_utf16().collect(); + if units.len() < 14 { + return None; + } + let digits = &units[..14]; + if !digits + .iter() + .all(|u| (b'0' as u16..=b'9' as u16).contains(u)) + { + return None; + } + let num = |range: std::ops::Range| -> i64 { + digits[range] + .iter() + .fold(0i64, |acc, u| acc * 10 + i64::from(*u - b'0' as u16)) + }; + let base = dart_utc( + num(0..4), + num(4..6), + num(6..8), + num(8..10), + num(10..12), + num(12..14), + )?; + + if units.len() < 20 { + return Some(base); + } + let offset = &units[15..20]; + let sign: i64 = if offset[0] == b'-' as u16 { -1 } else { 1 }; + let (Some(hours), Some(minutes)) = ( + dart_int_try_parse(&String::from_utf16_lossy(&offset[1..3])), + dart_int_try_parse(&String::from_utf16_lossy(&offset[3..5])), + ) else { + return Some(base); + }; + base.checked_sub_signed(TimeDelta::hours(sign * hours) + TimeDelta::minutes(sign * minutes)) + } +} + +/// `DateTime.utc(y, m, d, h, min, s)` de Dart, avec report des valeurs hors +/// bornes (mois 0 → décembre précédent, jour 0 → veille du 1er, etc.). +fn dart_utc( + year: i64, + month: i64, + day: i64, + hour: i64, + minute: i64, + second: i64, +) -> Option> { + let months = year * 12 + (month - 1); + let y = i32::try_from(months.div_euclid(12)).ok()?; + let m = u32::try_from(months.rem_euclid(12) + 1).ok()?; + let first = NaiveDate::from_ymd_opt(y, m, 1)?.and_hms_opt(0, 0, 0)?; + let delta = TimeDelta::days(day - 1) + + TimeDelta::hours(hour) + + TimeDelta::minutes(minute) + + TimeDelta::seconds(second); + Some(first.checked_add_signed(delta)?.and_utc()) +} + +/// `int.tryParse` de Dart (base 10) : espaces autour tolérés, signe +/// facultatif. +fn dart_int_try_parse(raw: &str) -> Option { + let s = dart_trim(raw); + let (negative, digits) = match s.as_bytes().first() { + Some(b'-') => (true, &s[1..]), + Some(b'+') => (false, &s[1..]), + _ => (false, s), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let value: i64 = digits.parse().ok()?; + Some(if negative { -value } else { value }) +} + +fn lookup( + index: &XmltvIndex, + channel_id: Option<&str>, + display_name: Option<&str>, +) -> Option>> { + let mut keys = vec![XmltvEpgService::normalize_key(channel_id)]; + if let Some(name) = display_name { + keys.extend(XmltvEpgService::name_keys(name)); + } + // Clés souples en dernier recours, dans leur propre espace de noms. + keys.push(loose_index_key(&XmltvEpgService::loose_key(channel_id))); + if let Some(name) = display_name { + let (cleaned, _) = XmltvEpgService::clean_channel_name(name); + keys.push(loose_index_key(&XmltvEpgService::loose_key(Some(&cleaned)))); + } + keys.iter() + .filter(|k| !k.is_empty()) + .find_map(|k| index.get(k).filter(|hit| !hit.is_empty()).cloned()) +} + +/// Ligne de journal d'une source en échec. L'URL (déjà masquée) peut être le +/// `xmltv.php` du panneau, identifiants de l'abonné en clair ; le texte de +/// l'erreur est masqué aussi, les erreurs HTTP recopiant l'URL demandée. +fn failure_line(safe_url: &str, error: &str) -> String { + format!("[XmltvEpg] {safe_url} : échec ({})", redact_url(error)) +} + +async fn refresh(inner: &Arc) { + let config = &inner.config; + if config.source_urls.is_empty() { + return; + } + + let mut merged = XmltvIndex::new(); + let mut ok = 0; + + for url in &config.source_urls { + // 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'erreur est + // masqué aussi, reqwest recopiant l'URL demandée. + let safe_url = redact_url(url); + match download_and_parse(inner, url).await { + Ok(parsed) => { + let count = parsed.len(); + // Première source servie gagne : les suivantes ne comblent que + // les chaînes encore absentes. + if merged.is_empty() { + merged = parsed; + } else { + for (key, programmes) in parsed { + merged.entry(key).or_insert(programmes); + } + } + ok += 1; + (inner.log)(&format!("[XmltvEpg] {safe_url} : {count} chaînes indexées")); + } + Err(error) => (inner.log)(&failure_line(&safe_url, &error)), + } + } + + if ok == 0 { + // Garder l'index précédent plutôt que de servir un guide vide. + inner.lock().failed_at = Some(Instant::now()); + (inner.log)("[XmltvEpg] aucune source disponible, index précédent conservé"); + return; + } + + let count = merged.len(); + let previous = { + let mut state = inner.lock(); + state.indexed_at = Some(Instant::now()); + state.failed_at = None; + std::mem::replace(&mut state.index, Arc::new(merged)) + }; + (inner.log)(&format!("[XmltvEpg] index prêt : {count} chaînes")); + // Libérer l'ancien index (des centaines de milliers de chaînes de + // caractères) hors de la boucle asynchrone. + tokio::task::spawn_blocking(move || drop(previous)); +} + +/// Télécharge `url` puis l'indexe sur un thread bloquant dédié. +/// +/// Décompresser et parser un dump de plusieurs milliers de chaînes sur la +/// boucle asynchrone 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. +async fn download_and_parse(inner: &Inner, url: &str) -> Result { + let timeout = inner.config.download_timeout; + let body = match tokio::time::timeout(timeout, download(&inner.client, url)).await { + Ok(result) => result?, + Err(_) => { + return Err(format!( + "TimeoutException after {}: Future not completed", + dart_duration(timeout) + )); + } + }; + let retention = inner.config.retention; + let horizon = inner.config.horizon; + tokio::task::spawn_blocking(move || decode_and_parse(&body, retention, horizon)) + .await + .map_err(|e| e.to_string())? +} + +async fn download(client: &reqwest::Client, url: &str) -> Result { + let response = client.get(url).send().await.map_err(|e| error_text(&e))?; + let status = response.status().as_u16(); + if status != 200 { + return Err(format!("HttpException: HTTP {status}")); + } + response.bytes().await.map_err(|e| error_text(&e)) +} + +/// Texte d'une erreur avec ses causes (reqwest cache la cause réelle — +/// connexion refusée, DNS… — derrière un message générique). +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 +} + +/// Format `Duration.toString()` de Dart : `0:05:00.000000`. +fn dart_duration(d: Duration) -> String { + let micros = d.as_micros(); + let hours = micros / 3_600_000_000; + let minutes = (micros / 60_000_000) % 60; + let seconds = (micros / 1_000_000) % 60; + let fraction = micros % 1_000_000; + format!("{hours}:{minutes:02}:{seconds:02}.{fraction:06}") +} + +fn decode_and_parse( + raw: &[u8], + retention: Duration, + horizon: Duration, +) -> Result { + // 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. + let inflated; + let bytes = if raw.len() > 2 && raw[0] == 0x1f && raw[1] == 0x8b { + let mut out = Vec::new(); + flate2::read::MultiGzDecoder::new(raw) + .read_to_end(&mut out) + .map_err(|e| format!("FormatException: {e}"))?; + inflated = out; + &inflated[..] + } else { + raw + }; + parse(&String::from_utf8_lossy(bytes), retention, horizon) +} + +#[derive(Clone, Copy, PartialEq)] +enum TextTag { + Title, + Desc, + DisplayName, +} + +/// Valeur d'un attribut (entités décodées), ou chaîne vide s'il est absent. +fn attr(event: &BytesStart<'_>, name: &[u8]) -> String { + let mut attributes = event.attributes(); + attributes.with_checks(false); + for attribute in attributes { + // Attribut mal formé : traité comme absent plutôt que de rejeter le + // dump entier. + let Ok(attribute) = attribute else { break }; + if attribute.key.as_ref() == name { + return decode_entities(&String::from_utf8_lossy(&attribute.value)).into_owned(); + } + } + String::new() +} + +/// Décodage des entités comme le paquet Dart `xml` : les cinq entités XML et +/// les références numériques ; toute entité inconnue ou invalide reste telle +/// quelle (une entité fautive ne doit pas faire échouer tout le dump). +fn decode_entities(input: &str) -> Cow<'_, str> { + let Some(first) = input.find('&') else { + return Cow::Borrowed(input); + }; + let mut out = String::with_capacity(input.len()); + out.push_str(&input[..first]); + let mut rest = &input[first..]; + while let Some(amp) = rest.find('&') { + out.push_str(&rest[..amp]); + rest = &rest[amp..]; + let Some(semicolon) = rest.find(';') else { + // Plus aucun `;` : aucune entité ne peut suivre. + break; + }; + match decode_entity(&rest[1..semicolon]) { + Some(c) => { + out.push(c); + rest = &rest[semicolon + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + Cow::Owned(out) +} + +fn decode_entity(entity: &str) -> Option { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => { + let number = entity.strip_prefix('#')?; + let code = if let Some(hex) = number.strip_prefix(['x', 'X']) { + if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(hex, 16).ok()? + } else { + if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + number.parse().ok()? + }; + char::from_u32(code) + } + } +} + +fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result { + let now = Utc::now(); + let cutoff = TimeDelta::from_std(retention) + .ok() + .and_then(|d| now.checked_sub_signed(d)) + .unwrap_or(DateTime::::MIN_UTC); + let limit = TimeDelta::from_std(horizon) + .ok() + .and_then(|d| now.checked_add_signed(d)) + .unwrap_or(DateTime::::MAX_UTC); + let mut by_channel: HashMap> = HashMap::new(); + + // Alias : plusieurs dumps déclarent avec un ou plusieurs + // 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. + // Ordre d'insertion conservé (`Map` ordonnée en Dart) : l'application + // des alias en dépend. + let mut aliases: Vec<(String, String)> = Vec::new(); + let mut alias_names: HashSet = HashSet::new(); + let mut put_alias = |name: String, id: &str| { + if alias_names.insert(name.clone()) { + aliases.push((name, id.to_string())); + } + }; + let mut display_names: Vec = Vec::new(); + + let mut channel_id: Option = None; + let mut programme_channel: Option = None; + let mut start: Option> = None; + let mut stop: Option> = None; + let mut current_tag: Option = None; + let mut title = String::new(); + let mut desc = String::new(); + let mut display_name = String::new(); + // Les programmes d'une chaîne se suivent : mémoriser la dernière clé + // évite de renormaliser le même identifiant des milliers de fois. + let mut last_key: Option<(String, String)> = None; + + let mut reader = Reader::from_str(xml); + { + // Lecture non validante, comme `parseEvents` côté Dart. + let config = reader.config_mut(); + config.trim_text(false); + config.check_end_names = false; + config.allow_unmatched_ends = true; + config.expand_empty_elements = false; + } + + loop { + let event = reader + .read_event() + .map_err(|e| format!("XmlParserException: {e} at {}", reader.error_position()))?; + match event { + Event::Eof => break, + Event::Start(ref e) | Event::Empty(ref e) => { + match e.name().as_ref() { + b"channel" => { + channel_id = Some(attr(e, b"id")); + display_name.clear(); + display_names.clear(); + } + b"programme" => { + programme_channel = Some(attr(e, b"channel")); + start = XmltvEpgService::parse_xmltv_date(Some(&attr(e, b"start"))); + stop = XmltvEpgService::parse_xmltv_date(Some(&attr(e, b"stop"))); + title.clear(); + desc.clear(); + } + b"title" => current_tag = Some(TextTag::Title), + b"desc" => current_tag = Some(TextTag::Desc), + b"display-name" => current_tag = Some(TextTag::DisplayName), + _ => {} + } + if matches!(event, Event::Empty(_)) { + current_tag = None; + } + } + Event::Text(ref t) => { + if let Some(tag) = current_tag { + let raw = String::from_utf8_lossy(t.as_ref()); + let text = decode_entities(&raw); + match tag { + TextTag::Title => title.push_str(&text), + TextTag::Desc => desc.push_str(&text), + TextTag::DisplayName => display_name.push_str(&text), + } + } + } + Event::CData(ref c) => { + if let Some(tag) = current_tag { + let text = String::from_utf8_lossy(c.as_ref()); + match tag { + TextTag::Title => title.push_str(&text), + TextTag::Desc => desc.push_str(&text), + TextTag::DisplayName => display_name.push_str(&text), + } + } + } + Event::End(ref e) => { + match e.name().as_ref() { + b"display-name" => { + display_names.push(std::mem::take(&mut display_name)); + } + b"channel" => { + let id = XmltvEpgService::normalize_key(channel_id.as_deref()); + for raw in &display_names { + let name = XmltvEpgService::normalize_key(Some(raw)); + if !id.is_empty() && !name.is_empty() && id != name { + put_alias(name, &id); + } + } + // Clés souples (espace de noms « ~ ») : premier arrivé + // gagne. + if !id.is_empty() { + let raws = std::iter::once(channel_id.as_deref()) + .chain(display_names.iter().map(|s| Some(s.as_str()))); + for raw in raws { + let loose = loose_index_key(&XmltvEpgService::loose_key(raw)); + if !loose.is_empty() { + put_alias(loose, &id); + } + } + } + channel_id = None; + } + b"programme" => { + if let (Some(channel), Some(s), Some(e)) = (&programme_channel, start, stop) + && e > cutoff + && s < limit + { + let key = match &last_key { + Some((raw, key)) if raw == channel => key.clone(), + _ => { + let key = XmltvEpgService::normalize_key(Some(channel)); + last_key = Some((channel.clone(), key.clone())); + key + } + }; + if !key.is_empty() { + by_channel.entry(key).or_default().push(XmltvProgramme { + title: dart_trim(&title).to_string(), + description: dart_trim(&desc).to_string(), + start: s, + stop: e, + }); + } + } + programme_channel = None; + start = None; + stop = None; + } + _ => {} + } + current_tag = None; + } + _ => {} + } + } + + let mut index: XmltvIndex = by_channel + .into_iter() + .map(|(key, mut list)| { + list.sort_by_key(|a| a.start); + (key, Arc::new(list)) + }) + .collect(); + + // Rendre les chaînes atteignables aussi par leur nom affiché. + for (name, id) in aliases { + if let Some(programmes) = index.get(&id).cloned() { + index.entry(name).or_insert(programmes); + } + } + + Ok(index) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::Router; + use axum::http::{StatusCode, Uri}; + use chrono::Timelike; + use std::io::Write; + use std::sync::atomic::{AtomicUsize, Ordering}; + + type Svc = XmltvEpgService; + + fn stamp(d: DateTime) -> String { + d.format("%Y%m%d%H%M%S +0000").to_string() + } + + /// 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. + fn fixture(start: DateTime, stop: DateTime) -> String { + format!( + r#" + + FR - FRANCE 2 + + Journal de 20h + L'info du soir + + +"#, + stamp(start), + stamp(stop) + ) + } + + fn offline_service() -> Svc { + Svc::new(XmltvConfig::new(vec![]), reqwest::Client::new()) + } + + /// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les + /// appels et renvoie `(statut, corps)`. + async fn serve( + respond: impl Fn(&Uri) -> (StatusCode, bytes::Bytes) + Send + Sync + 'static, + ) -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let respond = Arc::new(respond); + let counter = Arc::clone(&calls); + let app = Router::new().fallback(move |uri: Uri| { + let respond = Arc::clone(&respond); + let counter = Arc::clone(&counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + respond(&uri) + } + }); + 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}"), calls) + } + + async fn service_with(dump: String) -> (Svc, Arc) { + let body = bytes::Bytes::from(dump.into_bytes()); + let (base, calls) = serve(move |_| (StatusCode::OK, body.clone())).await; + let svc = Svc::new( + XmltvConfig::new(vec![format!("{base}/epg.xml")]), + reqwest::Client::new(), + ); + (svc, calls) + } + + fn now_s() -> DateTime { + Utc::now().with_nanosecond(0).unwrap() + } + + // --- normalizeKey --- + + #[test] + fn normalize_rapproche_les_identifiants_ponctues_differemment() { + // Le cœur du repli : sans cette normalisation, `France2.fr` du panneau + // et `France.2.fr` du dump ne se rencontrent jamais. + assert_eq!( + Svc::normalize_key(Some("France2.fr")), + Svc::normalize_key(Some("France.2.fr")) + ); + } + + #[test] + fn normalize_replie_la_casse_et_les_accents() { + assert_eq!(Svc::normalize_key(Some("Chérie 25")), "cherie25"); + } + + #[test] + fn normalize_rend_une_cle_vide_sur_entree_nulle_ou_sans_caractere_utile() { + assert_eq!(Svc::normalize_key(None), ""); + assert_eq!(Svc::normalize_key(Some("...")), ""); + } + + // --- nameKeys --- + + fn has(keys: &[String], k: &str) -> bool { + keys.iter().any(|x| x == k) + } + + #[test] + fn name_keys_retire_le_prefixe_pays_et_les_marqueurs_de_qualite() { + assert!(has(&Svc::name_keys("FR - TF1 FHD ◉"), "tf1")); + assert!(has(&Svc::name_keys("FR - FRANCE 4 FHD"), "france4")); + assert!(has(&Svc::name_keys("|FR| M6 HD"), "m6")); + assert!(has(&Svc::name_keys("[FR] Arte UHD 4K"), "arte")); + assert!(has(&Svc::name_keys("FR: RMC Story HEVC"), "rmcstory")); + } + + #[test] + fn name_keys_propose_aussi_la_forme_nom_pays() { + // Les dumps publics identifient souvent la chaîne par `TF1.fr`. + assert!(has(&Svc::name_keys("FR - TF1 FHD"), "tf1fr")); + } + + #[test] + fn name_keys_garde_la_variante_decalee_distincte() { + assert!(!has(&Svc::name_keys("FR - TF1 +1 FHD"), "tf1")); + } + + #[test] + fn name_keys_ne_reduit_pas_un_nom_court_a_rien() { + assert!(has(&Svc::name_keys("M6"), "m6")); + assert!(!has(&Svc::name_keys("HD"), "")); + } + + #[test] + fn clean_channel_name_rend_le_pays_et_developpe_f3() { + assert_eq!( + Svc::clean_channel_name("FR - F3 ALPES HD"), + ("France 3 ALPES".to_string(), Some("fr".to_string())) + ); + assert_eq!(Svc::clean_channel_name("TF1"), ("TF1".to_string(), None)); + // `\b` ASCII de Dart : « F2é » se développe, « F2x » non. + assert_eq!(Svc::clean_channel_name("F2é").0, "France 2é"); + assert_eq!(Svc::clean_channel_name("F2x").0, "F2x"); + } + + #[test] + fn plus_one_se_comporte_comme_le_lookahead_dart() { + assert_eq!(replace_plus_one("TF1 +1 FHD"), "TF1 FHD"); + assert_eq!(replace_plus_one("TF1 + 1"), "TF1 "); + assert!(plus_one_spans("Canal +10").is_empty()); + assert!(plus_one_spans("Canal+").is_empty()); + assert_eq!(replace_plus_one("A+1+1"), "A "); + } + + // --- correspondances élargies (chaînes vides mesurées en prod) --- + + fn channel(id: &str, name: &str, title: &str, start: DateTime) -> String { + format!( + r#" + {name} + {title}"#, + stamp(start), + stamp(start + TimeDelta::hours(1)) + ) + } + + #[tokio::test] + async fn f3_alpes_du_panneau_trouve_france_3_alpes() { + let now = now_s(); + let (svc, _) = service_with(format!( + "{}", + channel("France.3.-.Alpes.fr", "France 3 Alpes", "JT Alpes", now) + )) + .await; + let hits = svc.programmes_for(None, Some("FR - F3 ALPES HD")).await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "JT Alpes"); + } + + #[tokio::test] + async fn ab3_trouve_ab3_channel_fr_par_cle_souple() { + let now = now_s(); + let (svc, _) = service_with(format!( + "{}", + channel("AB3.Channel.fr", "AB3 Channel", "Série", now) + )) + .await; + let hits = svc + .programmes_for(Some("AB3.fr"), Some("FR - AB3 FHD")) + .await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "Série"); + } + + #[tokio::test] + async fn tf1_plus_1_reprend_le_guide_de_tf1_decale_d_une_heure() { + let now = now_s(); + let (svc, _) = service_with(format!( + "{}", + channel("TF1.fr", "TF1", "Le 13h", now) + )) + .await; + let hits = svc.programmes_for(None, Some("FR - TF1 +1 FHD")).await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "Le 13h"); + assert_eq!(hits[0].start, now + TimeDelta::hours(1)); + } + + #[tokio::test] + async fn une_chaine_plus_1_qui_a_son_propre_guide_le_garde() { + let now = now_s(); + let (svc, _) = service_with(format!( + "{}{}", + channel("Boomerang.fr", "Boomerang", "Normal", now), + channel("Boomerang.+1.fr", "Boomerang +1", "Décalé", now) + )) + .await; + let hits = svc.programmes_for(None, Some("FR - BOOMERANG +1")).await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "Décalé"); + } + + // --- programmesFor par nom --- + + #[tokio::test] + async fn trouve_le_guide_d_une_chaine_sans_epg_channel_id_par_son_nom() { + let now = now_s(); + let dump = format!( + r#" + TF1 HDTF1 + + Le 13h + + +"#, + stamp(now), + stamp(now + TimeDelta::hours(1)) + ); + let (svc, _) = service_with(dump).await; + let hits = svc.programmes_for(None, Some("FR - TF1 FHD ◉")).await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "Le 13h"); + } + + #[test] + fn indexe_chaque_display_name_pas_seulement_le_premier() { + let now = now_s(); + let index = offline_service().parse_for_test(&format!( + r#" + Premier NomSecond Nom + T +"#, + stamp(now), + stamp(now + TimeDelta::hours(1)) + )); + assert!(index.contains_key(&Svc::normalize_key(Some("Second Nom")))); + // Les alias partagent la liste de la chaîne, sans copie. + assert!(Arc::ptr_eq( + &index[&Svc::normalize_key(Some("Second Nom"))], + &index["xfr"] + )); + } + + // --- parseXmltvDate --- + + #[test] + fn date_applique_le_decalage_horaire_annonce() { + assert_eq!( + Svc::parse_xmltv_date(Some("20260811200000 +0200")), + Some(Utc.with_ymd_and_hms(2026, 8, 11, 18, 0, 0).unwrap()) + ); + } + + #[test] + fn date_sans_decalage_traitee_comme_utc() { + assert_eq!( + Svc::parse_xmltv_date(Some("20260811200000")), + Some(Utc.with_ymd_and_hms(2026, 8, 11, 20, 0, 0).unwrap()) + ); + } + + #[test] + fn date_rejette_une_valeur_tronquee() { + assert_eq!(Svc::parse_xmltv_date(Some("202608")), None); + assert_eq!(Svc::parse_xmltv_date(None), None); + assert_eq!(Svc::parse_xmltv_date(Some("2026O811200000")), None); + } + + #[test] + fn date_decalage_negatif_et_report_hors_bornes() { + assert_eq!( + Svc::parse_xmltv_date(Some("20260811200000 -0530")), + Some(Utc.with_ymd_and_hms(2026, 8, 12, 1, 30, 0).unwrap()) + ); + // Comme `DateTime.parse` de Dart : le 32 août devient le 1er septembre. + assert_eq!( + Svc::parse_xmltv_date(Some("20260832000000")), + Some(Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap()) + ); + } + + use chrono::TimeZone; + + // --- parse --- + + #[test] + fn indexe_un_programme_en_cours_et_decode_son_titre() { + let now = Utc::now(); + let index = offline_service().parse_for_test(&fixture( + now - TimeDelta::minutes(10), + now + TimeDelta::minutes(20), + )); + let programmes = index + .get(&Svc::normalize_key(Some("France2.fr"))) + .expect("chaîne indexée"); + assert_eq!(programmes.len(), 1); + assert_eq!(programmes[0].title, "Journal de 20h"); + assert_eq!(programmes[0].description, "L'info du soir"); + } + + #[test] + fn rend_la_chaine_atteignable_par_son_nom_affiche() { + let now = Utc::now(); + let index = offline_service().parse_for_test(&fixture(now, now + TimeDelta::minutes(20))); + assert!(index.contains_key(&Svc::normalize_key(Some("FR - FRANCE 2")))); + } + + #[test] + fn ecarte_les_programmes_termines_hors_fenetre_de_retention() { + let old = Utc::now() - TimeDelta::days(2); + let index = offline_service().parse_for_test(&fixture(old, old + TimeDelta::minutes(30))); + assert!(index.is_empty()); + } + + #[test] + fn decode_entites_cdata_et_tolere_les_entites_inconnues() { + let now = now_s(); + let index = offline_service().parse_for_test(&format!( + r#"Tom & Jerry  éA AT&Tbrut &]]>"#, + stamp(now), + stamp(now + TimeDelta::hours(1)) + )); + let programmes = index.get("abfr").expect("chaîne indexée"); + assert_eq!(programmes[0].title, "Tom & Jerry  éA AT&T"); + assert_eq!(programmes[0].description, "brut &"); + } + + #[test] + fn programmes_tries_par_heure_de_debut() { + let now = now_s(); + let index = offline_service().parse_for_test(&format!( + r#"BA"#, + stamp(now + TimeDelta::hours(1)), + stamp(now + TimeDelta::hours(2)), + stamp(now), + stamp(now + TimeDelta::hours(1)), + )); + let titles: Vec<_> = index["c"].iter().map(|p| p.title.as_str()).collect(); + assert_eq!(titles, ["A", "B"]); + } + + #[test] + fn to_json_au_format_dart() { + let p = XmltvProgramme { + title: "T".into(), + description: "D".into(), + start: Utc.with_ymd_and_hms(2026, 8, 11, 18, 0, 0).unwrap(), + stop: Utc.with_ymd_and_hms(2026, 8, 11, 19, 0, 0).unwrap(), + }; + assert_eq!( + p.to_json("42"), + serde_json::json!({ + "title": "T", + "description": "D", + "start": "2026-08-11T18:00:00.000Z", + "end": "2026-08-11T19:00:00.000Z", + "channel_id": "42", + }) + ); + } + + // --- ensureFresh --- + + #[tokio::test] + async fn ne_retelecharge_pas_tant_que_l_index_est_frais() { + let now = Utc::now(); + let (svc, calls) = service_with(fixture(now, now + TimeDelta::minutes(30))).await; + svc.ensure_fresh().await; + svc.ensure_fresh().await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(svc.has_data()); + } + + #[tokio::test] + async fn les_appels_concurrents_partagent_un_seul_telechargement() { + let now = Utc::now(); + let (svc, calls) = service_with(fixture(now, now + TimeDelta::minutes(30))).await; + futures::future::join_all((0..8).map(|_| svc.ensure_fresh())).await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(svc.has_data()); + } + + #[tokio::test] + async fn espace_les_tentatives_apres_un_echec() { + // Sans ce recul, une source morte était retéléchargée à chaque + // consultation du guide : une requête sortante par chaîne affichée. + let (base, calls) = serve(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + bytes::Bytes::from_static(b"nope"), + ) + }) + .await; + let svc = Svc::new( + XmltvConfig::new(vec![format!("{base}/epg.xml")]), + reqwest::Client::new(), + ); + svc.ensure_fresh().await; + svc.ensure_fresh().await; + svc.ensure_fresh().await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(!svc.has_data()); + } + + #[tokio::test] + async fn conserve_l_index_precedent_apres_un_echec() { + let now = Utc::now(); + let fail = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let body = bytes::Bytes::from(fixture(now, now + TimeDelta::minutes(30)).into_bytes()); + let flag = Arc::clone(&fail); + let (base, _) = serve(move |_| { + if flag.load(Ordering::SeqCst) { + (StatusCode::BAD_GATEWAY, bytes::Bytes::new()) + } else { + (StatusCode::OK, body.clone()) + } + }) + .await; + let mut config = XmltvConfig::new(vec![format!("{base}/epg.xml")]); + config.refresh_interval = Duration::ZERO; + let svc = Svc::new(config, reqwest::Client::new()); + svc.ensure_fresh().await; + let before = svc.channel_count(); + assert!(before > 0); + fail.store(true, Ordering::SeqCst); + svc.ensure_fresh().await; + assert_eq!(svc.channel_count(), before); + } + + #[tokio::test] + async fn decompresse_un_dump_gzip_sans_en_tete() { + let now = Utc::now(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(fixture(now, now + TimeDelta::minutes(30)).as_bytes()) + .unwrap(); + let body = bytes::Bytes::from(gz.finish().unwrap()); + let (base, _) = serve(move |_| (StatusCode::OK, body.clone())).await; + let svc = Svc::new( + XmltvConfig::new(vec![format!("{base}/epg.xml.gz")]), + reqwest::Client::new(), + ); + let hits = svc.programmes_for(Some("France2.fr"), None).await; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "Journal de 20h"); + } + + // --- réactivité du serveur --- + + #[tokio::test(flavor = "current_thread")] + async fn l_indexation_ne_gele_pas_la_boucle_d_evenements() { + // Décoder et parser un dump de plusieurs milliers de chaînes sur la + // boucle asynchrone 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. Runtime mono-thread : un + // parsing hors `spawn_blocking` gèlerait le battement ci-dessous. + let now = Utc::now(); + // ~7000 chaînes comme un vrai dump de panneau ; 3 programmes par + // chaîne (au lieu de 30 côté Dart) pour garder le test rapide. + let description = + "Résumé du programme, avec assez de texte pour peser comme un vrai guide. ".repeat(4); + let mut xml = String::from(r#""#); + for c in 0..7000 { + xml.push_str(&format!( + r#"FR - CHAINE {c}"# + )); + for p in 0..3 { + let start = now + TimeDelta::minutes(30 * p); + let stop = start + TimeDelta::minutes(30); + xml.push_str(&format!( + r#"Programme {p}{description}"#, + stamp(start), + stamp(stop) + )); + } + } + xml.push_str(""); + let (svc, _) = service_with(xml).await; + + // Un « battement » toutes les 10 ms : le plus long écart observé est + // le temps pendant lequel la boucle est restée bloquée. + let beat = Arc::new(Mutex::new((Instant::now(), Duration::ZERO))); + let heartbeat = { + let beat = Arc::clone(&beat); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(10)).await; + let mut b = beat.lock().unwrap(); + let gap = b.0.elapsed(); + b.1 = b.1.max(gap); + b.0 = Instant::now(); + } + }) + }; + svc.ensure_fresh().await; + heartbeat.abort(); + let (last, mut worst) = *beat.lock().unwrap(); + // Le dernier blocage n'est suivi d'aucun battement : le compter aussi. + worst = worst.max(last.elapsed()); + + assert!( + svc.channel_count() >= 7000, + "{} chaînes", + svc.channel_count() + ); + assert!( + worst < Duration::from_millis(250), + "boucle d'événements gelée {} ms", + worst.as_millis() + ); + } + + // --- horizon --- + + #[test] + fn ecarte_les_programmes_au_dela_de_l_horizon() { + // Un dump national couvre sept jours : tout garder ferait grossir + // l'index pour un guide qui n'affiche que le programme courant. + let mut config = XmltvConfig::new(vec![]); + config.horizon = Duration::from_secs(48 * 3600); + let svc = Svc::new(config, reqwest::Client::new()); + let far = Utc::now() + TimeDelta::days(5); + assert!( + svc.parse_for_test(&fixture(far, far + TimeDelta::hours(1))) + .is_empty() + ); + } + + // --- journalisation (xmltv_epg_log_redaction_test.dart) --- + + /// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de + /// l'abonné y figurent en clair, comme en production. + const PANEL_QUERY: &str = "/xmltv.php?username=john&password=hunter2"; + + fn capturing(url: String) -> (Svc, Arc>>) { + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&lines); + let svc = Svc::with_logger( + XmltvConfig::new(vec![url]), + reqwest::Client::new(), + Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string())), + ); + (svc, lines) + } + + #[tokio::test] + async fn masque_les_identifiants_de_l_url_quand_la_source_repond() { + let (base, _) = serve(|_| { + ( + StatusCode::OK, + bytes::Bytes::from_static(br#""#), + ) + }) + .await; + let (svc, lines) = capturing(format!("{base}{PANEL_QUERY}")); + svc.ensure_fresh().await; + let lines = lines.lock().unwrap().clone(); + let xmltv: Vec<_> = lines + .iter() + .filter(|l| l.starts_with("[XmltvEpg]")) + .collect(); + assert!(!xmltv.is_empty()); + for line in &xmltv { + assert!(!line.contains("john"), "{line}"); + assert!(!line.contains("hunter2"), "{line}"); + } + assert!( + lines + .iter() + .any(|l| l.contains("username=***&password=***")), + "{lines:?}" + ); + } + + #[tokio::test] + async fn masque_les_identifiants_de_l_url_et_de_l_erreur_en_cas_d_echec() { + // Les erreurs reqwest recopient l'URL demandée dans leur message : + // c'est par là que les identifiants fuyaient aussi. Port fermé → + // connexion refusée. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let (svc, lines) = capturing(format!("http://127.0.0.1:{port}{PANEL_QUERY}")); + svc.ensure_fresh().await; + let lines = lines.lock().unwrap().clone(); + let failure = lines + .iter() + .find(|l| l.contains("échec")) + .unwrap_or_else(|| panic!("aucune ligne d'échec journalisée : {lines:?}")); + assert!(!failure.contains("john"), "{failure}"); + assert!(!failure.contains("hunter2"), "{failure}"); + // URL masquée en tête de ligne, et dans le texte de l'erreur. + assert_eq!( + failure.matches("username=***&password=***").count(), + 2, + "{failure}" + ); + assert!(!svc.has_data()); + } + + #[test] + fn ligne_d_echec_masque_le_texte_de_l_exception() { + let line = failure_line( + &redact_url("http://panel.example:8080/xmltv.php?username=john&password=hunter2"), + "ClientException: Connection refused, uri=http://panel.example:8080/xmltv.php?username=john&password=hunter2", + ); + assert!(!line.contains("john")); + assert!(!line.contains("hunter2")); + assert!(line.contains("username=***&password=***")); + assert!(line.contains("Connection refused")); + assert!(line.starts_with( + "[XmltvEpg] http://panel.example:8080/xmltv.php?username=***&password=*** : échec (" + )); + } + + #[test] + fn duree_au_format_dart() { + assert_eq!(dart_duration(Duration::from_secs(300)), "0:05:00.000000"); + assert_eq!( + dart_duration(Duration::from_millis(3_723_500)), + "1:02:03.500000" + ); + } +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..ea87544 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,65 @@ +//! État partagé du serveur. + +use crate::api::epg::EpgApi; +use crate::api::proxy::ProxyHandler; +use crate::api::streaming::Streaming; +use crate::db::AppDatabase; +use crate::http::Req; +use crate::models::PlaylistConfig; +use crate::server::RateLimiter; +use crate::services::cleanup::CleanupService; +use crate::services::logo_catalog::LogoCatalog; +use crate::services::recording_scheduler::RecordingScheduler; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct AppState { + pub db: Arc, + pub web_path: String, + /// Points d'entrée réécrits avec `?v=` (asset_versioning). + pub versioned: HashMap, + /// Client HTTP qui suit les redirections. + pub http: reqwest::Client, + /// Client HTTP sans suivi des redirections (revalidées à la main). + pub http_manual: reqwest::Client, + pub scheduler: Arc, + pub streaming: Arc, + pub proxy: ProxyHandler, + pub epg: EpgApi, + pub logo_catalog: Arc, + pub cleanup: Arc, + pub rate_limit: RateLimiter, + pub login_rate_limit: RateLimiter, + pub allowed_origin: Option, + pub trusted_proxies: Vec, +} + +impl AppState { + /// Playlist Xtream de la requête : la première playlist de l'utilisateur + /// authentifié ; à défaut (proxy, streaming), celle du premier + /// utilisateur de la base. + pub fn get_playlist(&self, req: &Req) -> Option { + let playlist = match &req.user { + Some(user) => self.db.get_playlists(&user.id).into_iter().next(), + None => { + let users = self.db.get_all_users(); + users + .first() + .and_then(|u| self.db.get_playlists(&u.id).into_iter().next()) + } + }; + match playlist { + Some(p) => Some(PlaylistConfig { + dns: p.server_url, + username: p.username, + password: p.password, + }), + None => { + println!("[getPlaylist] WARNING: No playlist found, returning null"); + None + } + } + } +} + +pub type SharedState = Arc; diff --git a/server/src/util/asset_versioning.rs b/server/src/util/asset_versioning.rs new file mode 100644 index 0000000..27526c5 --- /dev/null +++ b/server/src/util/asset_versioning.rs @@ -0,0 +1,135 @@ +//! Versionnage des assets non hachés de la build web. +//! +//! POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js` +//! gardent le même nom d'une build à l'autre. Un reverse proxy qui met les +//! `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée +//! en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc +//! l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque +//! déploiement. En ajoutant `?v=` aux références, +//! chaque version a sa propre URL : aucun cache intermédiaire ne peut la +//! confondre avec la précédente. + +use md5::{Digest, Md5}; +use std::collections::HashMap; +use std::path::Path; + +/// Empreinte courte et stable de [bytes]. +pub fn content_version(bytes: &[u8]) -> String { + let digest = Md5::digest(bytes); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + hex[..12].to_string() +} + +/// Ajoute `?v=` à chaque référence entre guillemets à un asset de +/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`). +/// +/// Seules les références exactes sont réécrites : `vendor/x.js` ou une +/// référence déjà versionnée restent intactes. +pub fn version_asset_refs(content: &str, versions: &[(&str, String)]) -> String { + let mut out = content.to_string(); + for (asset, version) in versions { + for quote in ['"', '\''] { + let from = format!("{quote}{asset}{quote}"); + let to = format!("{quote}{asset}?v={version}{quote}"); + out = out.replace(&from, &to); + } + } + out +} + +/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à +/// être servis à la place des fichiers d'origine. +/// +/// Calculé une fois au démarrage : la build web est figée dans l'image. +pub fn build_versioned_entry_points(web_path: &str) -> HashMap { + let base = Path::new(web_path); + let read = |name: &str| std::fs::read_to_string(base.join(name)).ok(); + let version_of = |name: &str| { + std::fs::read(base.join(name)) + .ok() + .map(|b| content_version(&b)) + }; + + let mut rewritten = HashMap::new(); + + // Le bootstrap référence main.dart.js : sa version dépend donc de celle + // de main.dart.js, d'où une version calculée APRÈS réécriture. + if let (Some(bootstrap), Some(main_version)) = + (read("flutter_bootstrap.js"), version_of("main.dart.js")) + { + let versioned = version_asset_refs(&bootstrap, &[("main.dart.js", main_version)]); + let bootstrap_version = content_version(versioned.as_bytes()); + rewritten.insert("flutter_bootstrap.js".to_string(), versioned); + if let Some(index) = read("index.html") { + rewritten.insert( + "index.html".to_string(), + version_asset_refs(&index, &[("flutter_bootstrap.js", bootstrap_version)]), + ); + } + } + + let player_assets: Vec<(&str, String)> = ["xf-player-core.js", "xf-player.css"] + .into_iter() + .filter_map(|name| version_of(name).map(|v| (name, v))) + .collect(); + for page in ["player.html", "player_lite.html", "player_mobile.html"] { + if let Some(html) = read(page) { + rewritten.insert(page.to_string(), version_asset_refs(&html, &player_assets)); + } + } + rewritten +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versionne_la_balise_script_du_lecteur() { + assert_eq!( + version_asset_refs( + r#""#, + &[("xf-player-core.js", "abc123".into())] + ), + r#""# + ); + } + + #[test] + fn versionne_main_js_path_dans_la_config_flutter() { + assert_eq!( + version_asset_refs( + r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}"#, + &[("main.dart.js", "f00".into())] + ), + r#"{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}"# + ); + } + + #[test] + fn ne_touche_ni_un_nom_contenant_l_asset_ni_une_ref_versionnee() { + let html = r#""#; + assert_eq!( + version_asset_refs(html, &[("xf-player-core.js", "new".into())]), + html + ); + } + + #[test] + fn guillemets_simples_acceptes() { + assert_eq!( + version_asset_refs( + "", + &[("xf-player.css", "1".into())] + ), + "" + ); + } + + #[test] + fn empreinte_stable() { + assert_eq!(content_version(&[1, 2, 3]), content_version(&[1, 2, 3])); + assert_ne!(content_version(&[1, 2, 3]), content_version(&[1, 2, 4])); + assert_eq!(content_version(&[1, 2, 3]).len(), 12); + } +} diff --git a/server/src/util/b64.rs b/server/src/util/b64.rs new file mode 100644 index 0000000..a504a92 --- /dev/null +++ b/server/src/util/b64.rs @@ -0,0 +1,38 @@ +//! Décodage base64 des titres/descriptions EPG du panneau Xtream. + +use base64::Engine; +use base64::engine::general_purpose::{STANDARD, URL_SAFE}; + +/// Texte décodé si [raw] est du base64 valide contenant de l'UTF-8 valide, +/// sinon `None` (l'appelant garde alors la valeur brute, comme +/// `utf8.decode(base64Decode(x))` qui levait une exception). +pub fn decode_text(raw: &str) -> Option { + let bytes = STANDARD + .decode(raw.as_bytes()) + .or_else(|_| URL_SAFE.decode(raw.as_bytes())) + .ok()?; + String::from_utf8(bytes).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_le_base64_du_panneau() { + assert_eq!( + decode_text("Sm91cm5hbCBkZSAyMGg=").as_deref(), + Some("Journal de 20h") + ); + assert_eq!( + decode_text("TCdpbmZvIGR1IHNvaXI=").as_deref(), + Some("L'info du soir") + ); + } + + #[test] + fn garde_un_titre_en_clair() { + assert_eq!(decode_text("Journal"), None); + assert_eq!(decode_text("Le 13h"), None); + } +} diff --git a/server/src/util/log_redactor.rs b/server/src/util/log_redactor.rs new file mode 100644 index 0000000..dba45a3 --- /dev/null +++ b/server/src/util/log_redactor.rs @@ -0,0 +1,55 @@ +//! Masque les identifiants IPTV des URL avant qu'elles n'atteignent les logs. +//! +//! Xtream Codes embarque les identifiants à la fois dans la query string +//! (`?username=u&password=p`) et dans le chemin +//! (`/live///.ts`, `/movie/...`, `/series/...`). + +use regex::Regex; +use std::sync::LazyLock; + +static QUERY_CREDS: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)(username|password)=[^&\s]*").unwrap()); + +static PATH_CREDS: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)/(live|movie|series)/[^/\s]+/[^/\s]+/").unwrap()); + +/// Renvoie [url] avec les identifiants remplacés par `***`. +pub fn redact_url(url: &str) -> String { + let redacted = QUERY_CREDS.replace_all(url, "$1=***"); + PATH_CREDS + .replace_all(&redacted, "/$1/***/***/") + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn masque_la_query_string() { + let out = redact_url( + "http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams", + ); + assert!(!out.contains("john")); + assert!(!out.contains("hunter2")); + assert!(out.contains("username=***")); + assert!(out.contains("password=***")); + assert!(out.contains("action=get_live_streams")); + } + + #[test] + fn masque_les_segments_de_chemin() { + for kind in ["live", "movie", "series"] { + let out = redact_url(&format!("http://srv:8080/{kind}/john/hunter2/12345.ts")); + assert!(!out.contains("john"), "{kind}"); + assert!(!out.contains("hunter2"), "{kind}"); + assert!(out.contains(&format!("/{kind}/***/***/12345.ts")), "{kind}"); + } + } + + #[test] + fn laisse_les_url_sans_identifiants() { + let url = "http://srv:8080/images/logo.png"; + assert_eq!(redact_url(url), url); + } +} diff --git a/server/src/util/media_probe.rs b/server/src/util/media_probe.rs new file mode 100644 index 0000000..acec966 --- /dev/null +++ b/server/src/util/media_probe.rs @@ -0,0 +1,113 @@ +//! Inspection des fichiers média locaux (enregistrements) via ffprobe. +//! +//! Les résultats sont mémorisés tant que le fichier ne change pas : une +//! liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture, +//! et la playlist de lecture est rechargée en continu — sans cache, ffprobe +//! tournerait en boucle. + +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::UNIX_EPOCH; + +struct CacheEntry { + size: u64, + mtime_ms: u128, + value: String, +} + +static CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin` +/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger. +pub fn ffprobe_path() -> String { + if let Ok(path) = std::env::var("FFPROBE_PATH") + && !path.is_empty() + { + return path; + } + if cfg!(target_os = "linux") && std::path::Path::new("/usr/local/bin/ffprobe").exists() { + return "/usr/local/bin/ffprobe".to_string(); + } + "ffprobe".to_string() +} + +/// Durée du média en secondes, ou `None` si elle n'est pas mesurable. +pub async fn duration(path: &str) -> Option { + let value = probe(path, "duration", &["-show_entries", "format=duration"]).await?; + let seconds: f64 = value.parse().ok()?; + (seconds.is_finite() && seconds > 0.0).then_some(seconds) +} + +/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…). +pub async fn video_codec(path: &str) -> Option { + let value = probe( + path, + "videoCodec", + &[ + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + ], + ) + .await?; + (!value.is_empty()).then(|| value.to_lowercase()) +} + +fn stat(path: &str) -> Option<(u64, u128)> { + let meta = std::fs::metadata(path).ok()?; + let mtime = meta + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis(); + Some((meta.len(), mtime)) +} + +/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat +/// tant que taille et date de modification sont inchangées. +async fn probe(path: &str, field: &str, args: &[&str]) -> Option { + let (size, mtime_ms) = stat(path)?; + let key = format!("{field}@{path}"); + { + let cache = CACHE.lock().unwrap(); + if let Some(entry) = cache.get(&key) + && entry.size == size + && entry.mtime_ms == mtime_ms + { + return Some(entry.value.clone()); + } + } + + let output = tokio::process::Command::new(ffprobe_path()) + .args(["-v", "error"]) + .args(args) + .args(["-of", "default=noprint_wrappers=1:nokey=1", path]) + .stdin(std::process::Stdio::null()) + .output() + .await + .ok()?; + // ffprobe imprime une ligne par flux : seule la première nous intéresse. + let stdout = String::from_utf8_lossy(&output.stdout); + let first = stdout + .trim() + .split('\n') + .next() + .unwrap_or("") + .trim() + .to_string(); + if first.is_empty() || first == "N/A" { + return None; + } + CACHE.lock().unwrap().insert( + key, + CacheEntry { + size, + mtime_ms, + value: first.clone(), + }, + ); + Some(first) +} diff --git a/server/src/util/mod.rs b/server/src/util/mod.rs new file mode 100644 index 0000000..58b4599 --- /dev/null +++ b/server/src/util/mod.rs @@ -0,0 +1,7 @@ +pub mod asset_versioning; +pub mod b64; +pub mod log_redactor; +pub mod media_probe; +pub mod password; +pub mod process; +pub mod safe_path; diff --git a/server/src/util/password.rs b/server/src/util/password.rs new file mode 100644 index 0000000..621f8dc --- /dev/null +++ b/server/src/util/password.rs @@ -0,0 +1,77 @@ +//! Hachage des mots de passe : bcrypt, avec lecture des anciens hachages +//! SHA-256 non salés (migrés paresseusement à la connexion suivante). + +use regex::Regex; +use sha2::{Digest, Sha256}; +use std::sync::LazyLock; + +/// Coût 10 : identique à l'ancien serveur, les hachages existants restent +/// vérifiables et le temps de connexion ne change pas. +const BCRYPT_COST: u32 = 10; + +/// Les hachages historiques sont des empreintes SHA-256 hexadécimales. +static LEGACY: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-f]{64}$").unwrap()); + +pub fn hash(password: &str) -> Result { + bcrypt::hash(password, BCRYPT_COST).map_err(|e| e.to_string()) +} + +/// Vérifie [password] contre le hachage stocké (bcrypt ou SHA-256 historique). +pub fn verify(password: &str, stored: &str) -> bool { + if is_legacy(stored) { + let digest = Sha256::digest(password.as_bytes()); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + return hex == stored; + } + // Hachage malformé (ni SHA-256 ni bcrypt valide) : jamais valide. + bcrypt::verify(password, stored).unwrap_or(false) +} + +pub fn is_legacy(stored: &str) -> bool { + LEGACY.is_match(stored) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bcrypt_aller_retour() { + let h = hash("s3cret!").unwrap(); + assert!(verify("s3cret!", &h)); + assert!(!verify("wrong", &h)); + } + + #[test] + fn les_hachages_sha256_historiques_restent_valides() { + // sha256("admin") + let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; + assert!(verify("admin", legacy)); + assert!(!verify("other", legacy)); + } + + #[test] + fn is_legacy_ne_reconnait_que_le_sha256_hex() { + assert!(is_legacy( + "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" + )); + assert!(!is_legacy("$2a$10$abcdefghijklmnopqrstuv")); + assert!(!is_legacy(&hash("x").unwrap())); + } + + #[test] + fn lit_les_hachages_2a_de_l_ancien_serveur() { + // Hachage produit par le paquet Dart `bcrypt` (préfixe $2a$). + let dart = bcrypt::hash_with_salt("admin", 10, [7u8; 16]) + .unwrap() + .format_for_version(bcrypt::Version::TwoA); + assert!(dart.starts_with("$2a$10$")); + assert!(verify("admin", &dart)); + } + + #[test] + fn un_hachage_malforme_n_est_jamais_valide() { + assert!(!verify("x", "not-a-hash")); + assert!(!verify("", "")); + } +} diff --git a/server/src/util/process.rs b/server/src/util/process.rs new file mode 100644 index 0000000..28dcb97 --- /dev/null +++ b/server/src/util/process.rs @@ -0,0 +1,123 @@ +//! Processus enfants (FFmpeg) pilotés par signal. +//! +//! POURQUOI : `tokio::process::Child::kill` envoie SIGKILL. FFmpeg tué ainsi +//! ne finalise pas son fichier (index MKV absent, playlist HLS sans fin) : +//! l'ancien serveur envoyait SIGTERM, que FFmpeg traite comme un arrêt +//! propre. Le processus est donc confié à une tâche qui attend sa fin et +//! relaie les demandes d'arrêt sous forme de vrais signaux. + +use tokio::process::Child; +use tokio::sync::{mpsc, watch}; + +#[derive(Clone, Copy, Debug)] +enum Signal { + Term, + Kill, +} + +/// Processus suivi : signal d'arrêt, code de sortie observable. +#[derive(Clone)] +pub struct ManagedChild { + signals: mpsc::UnboundedSender, + exit: watch::Receiver>, +} + +impl ManagedChild { + /// Prend la main sur [child] (ses flux stdout/stderr doivent déjà avoir + /// été extraits par l'appelant s'il les lit). + pub fn spawn(mut child: Child) -> Self { + let (signal_tx, mut signal_rx) = mpsc::unbounded_channel::(); + let (exit_tx, exit_rx) = watch::channel(None); + #[cfg(unix)] + let pid = child.id(); + + tokio::spawn(async move { + loop { + tokio::select! { + status = child.wait() => { + let code = match status { + Ok(status) => exit_code(status), + Err(_) => -1, + }; + let _ = exit_tx.send(Some(code)); + break; + } + Some(signal) = signal_rx.recv() => { + match signal { + Signal::Term => { + #[cfg(unix)] + { + if let Some(pid) = pid { + // Le processus n'est pas encore récolté + // (wait() n'a pas abouti) : le pid est sûr. + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); } + } + } + #[cfg(not(unix))] + { + let _ = child.start_kill(); + } + } + Signal::Kill => { + let _ = child.start_kill(); + } + } + } + } + } + }); + + Self { + signals: signal_tx, + exit: exit_rx, + } + } + + /// Arrêt propre (SIGTERM). Sans effet si le processus est déjà terminé. + pub fn terminate(&self) { + let _ = self.signals.send(Signal::Term); + } + + /// Arrêt forcé (SIGKILL). + pub fn kill(&self) { + let _ = self.signals.send(Signal::Kill); + } + + /// Code de sortie, ou `None` si le processus tourne encore. + pub fn exit_code(&self) -> Option { + *self.exit.borrow() + } + + pub fn has_exited(&self) -> bool { + self.exit_code().is_some() + } + + /// Attend la fin du processus et renvoie son code de sortie. + pub async fn wait(&self) -> i32 { + let mut rx = self.exit.clone(); + loop { + if let Some(code) = *rx.borrow_and_update() { + return code; + } + if rx.changed().await.is_err() { + return rx.borrow().unwrap_or(-1); + } + } + } +} + +/// Code de sortie à la manière de Dart : négatif (−signal) pour un +/// processus tué par un signal. +fn exit_code(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return -signal; + } + } + -1 +} diff --git a/server/src/util/safe_path.rs b/server/src/util/safe_path.rs new file mode 100644 index 0000000..cd3f3ec --- /dev/null +++ b/server/src/util/safe_path.rs @@ -0,0 +1,75 @@ +//! Validation de chemins contre la traversée de répertoires. +//! +//! Travaille sur des chemins POSIX (le serveur tourne dans un conteneur +//! Linux), de façon purement lexicale comme `package:path`. + +/// Normalise un chemin POSIX absolu : résout `.` et `..`, retire les `/` +/// redondants. +fn normalize(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop(); + } + other => parts.push(other), + } + } + format!("/{}", parts.join("/")) +} + +fn absolute(path: &str) -> String { + if path.starts_with('/') { + return path.to_string(); + } + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| "/".to_string()); + format!("{}/{}", cwd.trim_end_matches('/'), path) +} + +/// Résout [candidate] et ne le renvoie que s'il reste dans [base_dir]. +/// `None` quand le chemin s'en échappe (segments `..`, chemin absolu ailleurs). +pub fn resolve_within(base_dir: &str, candidate: &str) -> Option { + let base = normalize(&absolute(base_dir)); + let resolved = if candidate.starts_with('/') { + normalize(candidate) + } else { + normalize(&format!("{base}/{candidate}")) + }; + let within = + resolved == base || resolved.starts_with(&format!("{}/", base.trim_end_matches('/'))); + within.then_some(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "/app/recordings"; + + #[test] + fn accepte_les_fichiers_du_dossier() { + assert!(resolve_within(BASE, "/app/recordings/show.log").is_some()); + assert!(resolve_within(BASE, "show.log").is_some()); + assert!(resolve_within(BASE, "/app/recordings/sub/dir/file.log").is_some()); + } + + #[test] + fn rejette_la_traversee() { + assert!(resolve_within(BASE, "/app/recordings/../data/xtremflow.db").is_none()); + assert!(resolve_within(BASE, "../../etc/passwd").is_none()); + } + + #[test] + fn rejette_les_chemins_absolus_ailleurs() { + assert!(resolve_within(BASE, "/etc/passwd").is_none()); + assert!(resolve_within(BASE, "/app/data/xtremflow.db").is_none()); + } + + #[test] + fn rejette_un_voisin_au_prefixe_commun() { + assert!(resolve_within(BASE, "/app/recordings-evil/file.log").is_none()); + } +}