mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
Merge remote-tracking branch 'origin/main' into claude/lecture-video
# Conflicts: # CHANGELOG.md
This commit is contained in:
35 files changed
+3146
-533
No files matched your search
@@ -8,7 +8,6 @@
|
|||||||
build/
|
build/
|
||||||
bin/.dart_tool
|
bin/.dart_tool
|
||||||
bin/.packages
|
bin/.packages
|
||||||
bin/pubspec.lock
|
|
||||||
ios/
|
ios/
|
||||||
android/
|
android/
|
||||||
windows/
|
windows/
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# Un push qui en suit un autre annule le run précédent : inutile de
|
||||||
|
# consommer un runner pour un commit déjà obsolète.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -13,7 +19,13 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
# Version épinglée : `channel: stable` seul fait dériver le SDK au
|
||||||
|
# fil du temps (une release Flutter peut casser la CI sans aucun
|
||||||
|
# changement dans le dépôt). Le cache évite de retélécharger SDK et
|
||||||
|
# dépendances pub à chaque run.
|
||||||
|
flutter-version: 3.38.4
|
||||||
channel: stable
|
channel: stable
|
||||||
|
cache: true
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
# Infos (pre-existing withOpacity/dart:html deprecations) are not
|
# Infos (pre-existing withOpacity/dart:html deprecations) are not
|
||||||
# fatal; errors and warnings still fail the build.
|
# fatal; errors and warnings still fail the build.
|
||||||
@@ -30,7 +42,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dart-lang/setup-dart@v1
|
- uses: dart-lang/setup-dart@v1
|
||||||
with:
|
with:
|
||||||
sdk: stable
|
sdk: 3.13.2
|
||||||
- run: dart pub get
|
- run: dart pub get
|
||||||
- run: dart analyze
|
- run: dart analyze
|
||||||
- run: dart test
|
- run: dart test
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
name: Docker Publish
|
name: Docker Publish
|
||||||
|
|
||||||
|
# Chaîné sur la CI : l'image :latest n'est publiée que si analyze/test/build
|
||||||
|
# sont verts sur main. L'ancien déclencheur `push: [main]` tournait en
|
||||||
|
# parallèle de la CI et pouvait publier une image cassée.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -11,11 +16,18 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Sur workflow_run, github.sha pointe sur le commit du workflow
|
||||||
|
# par défaut : construire exactement le commit validé par la CI.
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -34,6 +46,6 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_NAME }}:latest
|
${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
${{ env.IMAGE_NAME }}:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
@@ -49,4 +49,7 @@ app.*.map.json
|
|||||||
# Hive data (local development)
|
# Hive data (local development)
|
||||||
*.hive
|
*.hive
|
||||||
*.lock
|
*.lock
|
||||||
|
# Les lockfiles pub sont versionnés : sans eux, chaque build Docker/CI
|
||||||
|
# résout des versions différentes (builds non reproductibles).
|
||||||
|
!pubspec.lock
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@@ -8,6 +8,33 @@
|
|||||||
- **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur)
|
- **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur)
|
||||||
- **Latence live maîtrisée** : rattrapage du direct activé dans mpegts.js (profil rapide) — les micro-coupures ne font plus dériver la lecture derrière le direct
|
- **Latence live maîtrisée** : rattrapage du direct activé dans mpegts.js (profil rapide) — les micro-coupures ne font plus dériver la lecture derrière le direct
|
||||||
|
|
||||||
|
### 🧰 Qualité / Infra
|
||||||
|
- **Builds reproductibles** : `pubspec.lock` (frontend et backend) désormais versionnés et utilisés par le Dockerfile — chaque build résolvait jusqu'ici des versions fraîches
|
||||||
|
- **CI durcie** : versions Flutter/Dart épinglées, cache pub, annulation des runs obsolètes (`concurrency`) ; `docker-publish` ne publie plus `:latest` qu'après une CI verte sur main (il tournait en parallèle et pouvait publier une image cassée)
|
||||||
|
- **docker-compose** : défaut `RECORDINGS_PATH` porté à `./data/recordings` (l'ancien défaut était un chemin unRAID spécifique à une machine), variable `TZ` ajoutée
|
||||||
|
- **README réécrit** : il décrivait une architecture disparue (Hive/IndexedDB, SHA-256, dhttpd port 8080) — remplacé par l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, CI)
|
||||||
|
- `DEPLOYMENT_CHECKLIST.md` archivé et avertissement ajouté sur `docs/archive/` (plusieurs documents s'y déclarent « COMPLETE » à tort)
|
||||||
|
|
||||||
|
### ✨ Fonctionnalités
|
||||||
|
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
|
||||||
|
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
|
||||||
|
- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions
|
||||||
|
- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule
|
||||||
|
- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard)
|
||||||
|
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes
|
||||||
|
|
||||||
|
### 🔧 Fiabilité des enregistrements
|
||||||
|
- **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé
|
||||||
|
- **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin)
|
||||||
|
- **SQLite durci** : `foreign_keys=ON` (les CASCADE déclarés s'appliquent enfin), WAL, `busy_timeout`, migrations de schéma versionnées, index sur `user_id`/`start_time`
|
||||||
|
- **Gestion disque** : refus explicite de démarrer une capture sous `MIN_FREE_DISK_MB` (défaut 500 Mo) ; nouvelle rotation par quota d'octets (`RECORDINGS_QUOTA_GB`, désactivée par défaut) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne BDD ensemble (l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours) ; la suppression d'un enregistrement efface aussi ses fichiers (.mkv, .log, parties)
|
||||||
|
- **Arrêt gracieux** : `docker stop` clôture proprement les enregistrements (fusion des parties, statut en base) avant de tuer les sessions de streaming
|
||||||
|
- **Noms de fichiers uniques** (fragment d'id) : deux enregistrements du même programme ne s'écrasent plus
|
||||||
|
- **Statut `cancelled`** : arrêter un enregistrement planifié l'annule au lieu de le marquer « terminé » sans fichier (lecture cassée)
|
||||||
|
- **Season passes** : la playlist du propriétaire du pass est résolue à chaque scan (plus d'injection figée du premier utilisateur), correspondance de titre exacte par défaut (`match_mode`), plafond de créations par scan, réalignement automatique des horaires si le programme est déplacé dans l'EPG, déduplication tolérante (±2 min)
|
||||||
|
- **API de suivi** : `GET /api/recordings` renvoie désormais `progress_pct`, `file_size_bytes`, `retry_count`, `is_active` ; la liste affiche la barre de progression et la taille
|
||||||
|
- Le scheduler ne relit plus toute la table toutes les 10 s (requête filtrée sur `scheduled`/`recording`)
|
||||||
|
|
||||||
### 📺 Enregistrements
|
### 📺 Enregistrements
|
||||||
- La liste des enregistrements se met à jour automatiquement : rafraîchissement immédiat dès qu'un enregistrement est créé/arrêté n'importe où dans l'app (guide EPG, modal, widget rapide), et polling en arrière-plan (5 s quand un enregistrement est en cours ou planifié, 20 s sinon) pour suivre les statuts sans clic manuel
|
- La liste des enregistrements se met à jour automatiquement : rafraîchissement immédiat dès qu'un enregistrement est créé/arrêté n'importe où dans l'app (guide EPG, modal, widget rapide), et polling en arrière-plan (5 s quand un enregistrement est en cours ou planifié, 20 s sinon) pour suivre les statuts sans clic manuel
|
||||||
- Indicateur « Suivi auto » avec heure de dernière actualisation dans l'onglet Enregistrements
|
- Indicateur « Suivi auto » avec heure de dernière actualisation dans l'onglet Enregistrements
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,10 @@ ENV FLUTTER_NO_ANALYTICS=1
|
|||||||
RUN flutter config --enable-web && flutter precache --web
|
RUN flutter config --enable-web && flutter precache --web
|
||||||
|
|
||||||
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
||||||
COPY pubspec.yaml ./
|
# Les lockfiles sont versionnés : le build résout exactement les versions
|
||||||
COPY bin/pubspec.yaml ./bin/
|
# committées au lieu d'en chercher de nouvelles à chaque build.
|
||||||
# Note: No .lock files found locally, so we fetch fresh ones here
|
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 && cd bin && dart pub get
|
||||||
|
|
||||||
# 3. Source Code Copy (CHANGES OFTEN)
|
# 3. Source Code Copy (CHANGES OFTEN)
|
||||||
|
|||||||
@@ -1,282 +1,103 @@
|
|||||||
# XtremFlow - IPTV Web Application
|
# XtremFlow — Application Web IPTV
|
||||||
|
|
||||||
High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API.
|
Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, empaquetés dans une seule image Docker. Se connecte à un abonnement Xtream Codes et ajoute le magnétoscope (enregistrements planifiés, season passes), l'EPG avec repli XMLTV, et le transcodage FFmpeg à la demande.
|
||||||
|
|
||||||
## Features
|
## Fonctionnalités
|
||||||
|
|
||||||
✅ **Local Authentication System**
|
- **Live TV, Films, Séries** : catalogues Xtream avec catégories, recherche, favoris, reprise de lecture
|
||||||
- Default admin user (`admin`/`admin`)
|
- **Guide TV (EPG)** : panneau de l'abonné en priorité, repli automatique sur un dump XMLTV quand le panneau est figé
|
||||||
- Secure salt-based password hashing (SHA-256)
|
- **Enregistrements TV** : planification depuis le guide, capture FFmpeg (`-c copy`), reprise après coupure amont ou redémarrage du serveur, fusion automatique des parties
|
||||||
- No public signup - private app only
|
- **Season Passes** : enregistrement automatique de toutes les diffusions d'une émission (scan EPG toutes les 4 h)
|
||||||
|
- **Transcodage à la demande** : `source | high | medium | low` (live et VOD), `source` = zéro transcodage ; NVENC optionnel
|
||||||
|
- **Multi-utilisateurs** : comptes locaux (bcrypt), playlists par utilisateur, panneau d'administration
|
||||||
|
|
||||||
✅ **Multi-Playlist Management**
|
## Stack
|
||||||
- Centralized Xtream credentials management
|
|
||||||
- Playlist assignment to users
|
|
||||||
- Easy switching between playlists
|
|
||||||
|
|
||||||
✅ **High-Performance Dashboard (60fps)**
|
| Couche | Techno |
|
||||||
- Category-based pagination (100 items/page for Live TV, 50 for Movies)
|
|---|---|
|
||||||
- Lazy loading with `ListView.builder` / `GridView.builder`
|
| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) |
|
||||||
- Image caching with `cached_network_image`
|
| Backend | Dart compilé en natif (`dart compile exe`), shelf |
|
||||||
|
| Base | SQLite côté serveur (`/app/data/xtremflow.db`) |
|
||||||
|
| Capture/Transcodage | FFmpeg (build BtbN, NVENC inclus) |
|
||||||
|
| Conteneur | Debian slim multi-stage, port **8089** |
|
||||||
|
|
||||||
✅ **Live TV with EPG**
|
## Démarrage rapide (Docker)
|
||||||
- Electronic Program Guide (EPG) overlay
|
|
||||||
- "Now & Next" program display
|
|
||||||
- Real-time progress bar
|
|
||||||
|
|
||||||
✅ **VOD & Series**
|
|
||||||
- Movies and Series organized by categories
|
|
||||||
- Grid layout with posters
|
|
||||||
- Optimized ratings display (1 decimal place)
|
|
||||||
|
|
||||||
✅ **Docker Deployment**
|
|
||||||
- Multi-stage build with Flutter and Dart
|
|
||||||
- Custom Dart Server (`bin/server.dart`)
|
|
||||||
- **FFmpeg Transcoding** for mobile compatibility
|
|
||||||
- **Cache Management** system for temporary files
|
|
||||||
- External network support (`nginx_default`)
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Framework**: Flutter Web
|
|
||||||
- **State Management**: Riverpod
|
|
||||||
- **Local Database**: Hive (Web IndexedDB) with AES encryption
|
|
||||||
- **Networking**: Dio with cache interceptors
|
|
||||||
- **Routing**: GoRouter with auth guards
|
|
||||||
- **Video Player**: `video_player` + `chewie`
|
|
||||||
- **UI**: Google Fonts, Material Design 3
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- Existing `nginx_default` network (for reverse proxy routing)
|
|
||||||
- Flutter SDK (for local development only)
|
|
||||||
|
|
||||||
## Quick Start (Docker)
|
|
||||||
|
|
||||||
### 1. Build the Docker image
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
docker-compose up -d --build
|
||||||
|
# Application sur http://localhost:8089
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Start the container
|
Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléatoire affiché une seule fois dans les logs** (`docker logs xtremflow`), sauf si `ADMIN_INITIAL_PASSWORD` est défini. Changez-le après la première connexion.
|
||||||
|
|
||||||
```bash
|
### Variables d'environnement (docker-compose.yml)
|
||||||
docker-compose up -d
|
|
||||||
```
|
| Variable | Défaut | Rôle |
|
||||||
|
|---|---|---|
|
||||||
### 3. Access via reverse proxy
|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
||||||
|
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||||
Configure your reverse proxy (Nginx/Traefik) to route traffic to:
|
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
||||||
- **Container**: `xtremflow`
|
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
|
||||||
- **Internal Port**: `8080`
|
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
|
||||||
- **Network**: `nginx_default`
|
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
|
||||||
|
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
|
||||||
Example Nginx configuration:
|
| `RECORDINGS_QUOTA_GB` | `0` (off) | Quota du dossier d'enregistrements (rotation des plus anciens terminés) |
|
||||||
|
| `TRUSTED_PROXIES` | loopback + RFC1918 | IPs de reverse proxy dont `X-Forwarded-For` est honoré |
|
||||||
```nginx
|
|
||||||
location /iptv {
|
## Développement local
|
||||||
proxy_pass http://xtremflow:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Login
|
|
||||||
|
|
||||||
- **URL**: `http://your-domain/iptv`
|
|
||||||
- **Default Credentials**:
|
|
||||||
- Username: `admin`
|
|
||||||
- Password: `admin`
|
|
||||||
|
|
||||||
⚠️ **Change the admin password immediately after first login!**
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
### Install dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Frontend
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
flutter analyze && flutter test
|
||||||
|
flutter run -d chrome # nécessite le backend lancé pour les routes /api
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd bin
|
||||||
|
dart pub get
|
||||||
|
dart analyze && dart test
|
||||||
|
dart run server.dart --port 8089 --path ../build/web
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate Hive adapters (if modified)
|
Le build Windows natif est documenté dans `BUILD.md`.
|
||||||
|
|
||||||
```bash
|
## Structure du projet
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run web app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter run -d chrome
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
bin/ Serveur Dart
|
||||||
├── core/
|
├── server.dart Point d'entrée, routage, arrêt gracieux
|
||||||
│ ├── database/
|
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…)
|
||||||
│ │ └── hive_service.dart # Hive initialization & encryption
|
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV
|
||||||
│ ├── models/
|
├── database/ SQLite (schéma, migrations)
|
||||||
│ │ ├── app_user.dart # User model (Hive)
|
├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés)
|
||||||
│ │ ├── playlist_config.dart # Playlist credentials (Hive)
|
└── test/ Tests backend (dart test)
|
||||||
│ │ └── iptv_models.dart # Channel, VOD, Series, EPG models
|
|
||||||
│ ├── router/
|
lib/ Frontend Flutter
|
||||||
│ │ └── app_router.dart # GoRouter configuration
|
├── core/ Modèles, thème, router, clients API
|
||||||
│ └── utils/
|
├── features/ auth / admin / iptv (desktop)
|
||||||
│ └── crypto_utils.dart # Password hashing utilities
|
└── mobile/ Variantes d'écrans mobiles
|
||||||
├── features/
|
|
||||||
│ ├── auth/
|
web/ Players HTML + libs vendorisées (hls.js, mpegts.js)
|
||||||
│ │ ├── providers/
|
|
||||||
│ │ │ └── auth_provider.dart # Authentication state
|
|
||||||
│ │ └── screens/
|
|
||||||
│ │ └── login_screen.dart
|
|
||||||
│ ├── admin/
|
|
||||||
│ │ └── screens/
|
|
||||||
│ │ └── admin_panel.dart # User & Playlist CRUD
|
|
||||||
│ └── iptv/
|
|
||||||
│ ├── services/
|
|
||||||
│ │ └── xtream_service.dart # Xtream API client
|
|
||||||
│ ├── providers/
|
|
||||||
│ │ └── xtream_provider.dart # Riverpod providers
|
|
||||||
│ ├── screens/
|
|
||||||
│ │ └── player_screen.dart # Video player
|
|
||||||
│ └── widgets/
|
|
||||||
│ ├── live_tv_tab.dart # Live TV with pagination
|
|
||||||
│ ├── movies_tab.dart # Movies grid
|
|
||||||
│ ├── series_tab.dart # Series grid
|
|
||||||
│ └── epg_overlay.dart # EPG display
|
|
||||||
└── main.dart
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Features
|
## Sécurité
|
||||||
|
|
||||||
### Password Storage
|
- Mots de passe **bcrypt** (migration lazy depuis les anciens hashes au login)
|
||||||
- **Algorithm**: SHA-256 with random UUID-based salt
|
- Les credentials Xtream ne quittent jamais le serveur : passerelle `/api/xtream-api` et proxy `/api/xtream` (authentifié par session) avec injection côté serveur
|
||||||
- **Format**: `salt:hash` (stored in Hive)
|
- Redaction des credentials dans tous les logs ; anti-SSRF avec revalidation des redirections
|
||||||
- **Legacy Support**: Fallback to unsalted comparison for migration
|
- Cookie de session HttpOnly ; rate limiting global + limite de tentatives de login par IP
|
||||||
|
|
||||||
### Database Encryption
|
## CI / Publication
|
||||||
- **Hive AES Cipher** (256-bit key)
|
|
||||||
- Key stored in `FlutterSecureStorage`
|
|
||||||
- Automatic key generation on first run
|
|
||||||
|
|
||||||
### Authentication Flow
|
`ci.yml` (analyze + tests + build web/backend) tourne sur chaque PR et push main ; `docker-publish.yml` publie `ghcr.io/r0m1k3/xtremflow:latest` **uniquement après une CI verte** sur main.
|
||||||
1. User enters credentials
|
|
||||||
2. System retrieves stored hash
|
|
||||||
3. Input password is hashed with same salt
|
|
||||||
4. Constant-time comparison prevents timing attacks
|
|
||||||
|
|
||||||
## Performance Optimizations
|
## Dépannage
|
||||||
|
|
||||||
### Memory Management (20k+ channels)
|
- **Logs** : `docker logs xtremflow` (les URLs y sont redactées)
|
||||||
- **Grouping**: Channels organized by category
|
- **Mot de passe admin perdu** : supprimer le volume `xtremflow-data` recrée la base et affiche un nouveau mot de passe (⚠ efface utilisateurs et historique d'enregistrements)
|
||||||
- **Pagination**: 100 items per page (Live TV), 50 per page (Movies)
|
- **EPG vide** : vérifier `EPG_XMLTV_URLS` et que la chaîne a un `epg_channel_id` ; l'en-tête `X-Epg-Source` des réponses `/api/epg/<id>` indique la source utilisée
|
||||||
- **Lazy Loading**: Only render visible items
|
- **Enregistrement échoué** : bouton « Logs » sur la ligne de l'enregistrement ; la raison (`error_reason`) est affichée sous le titre
|
||||||
- **Image Caching**: Disk/memory cache with `cached_network_image`
|
|
||||||
|
|
||||||
### Network Optimization
|
## Licence
|
||||||
- **Dio Cache Interceptor**: 1-hour cache for API responses
|
|
||||||
- **EPG Cache**: 5-minute refresh for program data
|
|
||||||
- **Hive Disk Store**: Persistent cache across sessions
|
|
||||||
|
|
||||||
### Rendering (60fps Target)
|
Propriétaire — usage privé uniquement.
|
||||||
- `ListView.builder` with fixed `itemExtent`
|
|
||||||
- `AutomaticKeepAliveClientMixin` for tab state
|
|
||||||
- Expansion panels for category navigation
|
|
||||||
- Grid with fixed `crossAxisCount` and `childAspectRatio`
|
|
||||||
|
|
||||||
## Xtream API Integration
|
|
||||||
|
|
||||||
### Supported Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Purpose | Caching |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `player_api.php` | Authentication | 1 hour |
|
|
||||||
| `get_live_streams` | Live TV channels | 1 hour |
|
|
||||||
| `get_vod_streams` | Movies | 1 hour |
|
|
||||||
| `get_series` | Series | 1 hour |
|
|
||||||
| `get_short_epg` | EPG data | 5 minutes |
|
|
||||||
|
|
||||||
### Stream URL Formats
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// Live TV
|
|
||||||
http://[dns]/live/[username]/[password]/[stream_id].m3u8
|
|
||||||
|
|
||||||
// Movies
|
|
||||||
http://[dns]/movie/[username]/[password]/[stream_id].[container_extension]
|
|
||||||
|
|
||||||
// Series
|
|
||||||
http://[dns]/series/[username]/[password]/[stream_id].[container_extension]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Configuration
|
|
||||||
|
|
||||||
### Dockerfile (Multi-Stage)
|
|
||||||
|
|
||||||
**Stage 1: Builder**
|
|
||||||
- Base: `cirrusci/flutter:stable`
|
|
||||||
- Build: `flutter build web --release --web-renderer html`
|
|
||||||
|
|
||||||
**Stage 2: Runtime**
|
|
||||||
- Base: `dart:stable`
|
|
||||||
- Server: `dhttpd --host 0.0.0.0 --port 8080`
|
|
||||||
- Size: ~150MB (compressed)
|
|
||||||
|
|
||||||
### docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
iptv-web:
|
|
||||||
build: .
|
|
||||||
container_name: xtremflow
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- nginx_default
|
|
||||||
|
|
||||||
networks:
|
|
||||||
nginx_default:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
**No port mapping** - Access via reverse proxy only.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container won't start
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
docker logs xtremflow
|
|
||||||
|
|
||||||
# Verify network exists
|
|
||||||
docker network ls | grep nginx_default
|
|
||||||
|
|
||||||
# Create network if missing
|
|
||||||
docker network create nginx_default
|
|
||||||
```
|
|
||||||
|
|
||||||
### Login fails with admin/admin
|
|
||||||
- Check Hive database initialization in logs
|
|
||||||
- Verify `HiveService.init()` completed successfully
|
|
||||||
- Default admin is seeded only if `users` box is empty
|
|
||||||
|
|
||||||
### EPG not displaying
|
|
||||||
- EPG is optional and gracefully degrades
|
|
||||||
- Check if Xtream server supports `get_short_epg`
|
|
||||||
- Verify stream has `epg_channel_id`
|
|
||||||
|
|
||||||
### Performance issues (FPS drops)
|
|
||||||
- Reduce `_itemsPerPage` constant (currently 100 for Live TV)
|
|
||||||
- Disable image caching temporarily
|
|
||||||
- Check browser DevTools Performance tab
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Proprietary - Private Use Only
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For Xtream API documentation, consult your IPTV provider.
|
|
||||||
+11
-2
@@ -3,6 +3,7 @@ import 'package:shelf/shelf.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
import '../services/xmltv_epg_service.dart';
|
import '../services/xmltv_epg_service.dart';
|
||||||
|
import '../utils/log_redactor.dart';
|
||||||
|
|
||||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||||
/// GET /api/epg/<channel_id>?days=1
|
/// GET /api/epg/<channel_id>?days=1
|
||||||
@@ -112,8 +113,11 @@ class EpgApi {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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(
|
return Response.internalServerError(
|
||||||
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
|
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,7 +262,12 @@ class EpgApi {
|
|||||||
|
|
||||||
return {'channel_id': channelId, 'programmes': programmes};
|
return {'channel_id': channelId, 'programmes': programmes};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
|
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
||||||
|
return {
|
||||||
|
'channel_id': channelId,
|
||||||
|
'programmes': [],
|
||||||
|
'error': 'EPG indisponible',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-15
@@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../database/database.dart';
|
||||||
|
import '../middleware/auth_middleware.dart';
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
import '../utils/log_redactor.dart';
|
import '../utils/log_redactor.dart';
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ bool isForbiddenProxyHost(String host) {
|
|||||||
/// Handler for the Xtream Proxy
|
/// Handler for the Xtream Proxy
|
||||||
class ProxyHandler {
|
class ProxyHandler {
|
||||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||||
|
final AppDatabase _db;
|
||||||
final http.Client _client = http.Client();
|
final http.Client _client = http.Client();
|
||||||
|
|
||||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||||
@@ -70,7 +73,7 @@ class ProxyHandler {
|
|||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProxyHandler(this._getPlaylist);
|
ProxyHandler(this._getPlaylist, this._db);
|
||||||
|
|
||||||
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
||||||
Handler get handler {
|
Handler get handler {
|
||||||
@@ -84,8 +87,16 @@ class ProxyHandler {
|
|||||||
return Response.notFound(null);
|
return Response.notFound(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
|
// Authentification par session. Les requêtes initiées par le navigateur
|
||||||
// SSRF protection is still active via domain validation below.
|
// (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;
|
Uri? targetUrl;
|
||||||
|
|
||||||
@@ -139,6 +150,7 @@ class ProxyHandler {
|
|||||||
targetUrl.path.contains('/picons/') ||
|
targetUrl.path.contains('/picons/') ||
|
||||||
targetUrl.path.contains('/logos/');
|
targetUrl.path.contains('/logos/');
|
||||||
|
|
||||||
|
String? allowedHost;
|
||||||
if (!isStaticAsset) {
|
if (!isStaticAsset) {
|
||||||
// For API calls, enforce domain allowlist
|
// For API calls, enforce domain allowlist
|
||||||
final playlist = await _getCachedPlaylist(request);
|
final playlist = await _getCachedPlaylist(request);
|
||||||
@@ -149,7 +161,7 @@ class ProxyHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final targetHost = targetUrl.host.toLowerCase();
|
final targetHost = targetUrl.host.toLowerCase();
|
||||||
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||||||
|
|
||||||
if (targetHost != allowedHost) {
|
if (targetHost != allowedHost) {
|
||||||
print(
|
print(
|
||||||
@@ -175,7 +187,6 @@ class ProxyHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
||||||
final proxyRequest = http.Request(request.method, targetUrl);
|
|
||||||
|
|
||||||
// Forward safe request headers
|
// Forward safe request headers
|
||||||
for (final header in _allowedRequestHeaders) {
|
for (final header in _allowedRequestHeaders) {
|
||||||
@@ -184,18 +195,61 @@ class ProxyHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyRequest.headers.addAll(proxyHeaders);
|
List<int>? postBody;
|
||||||
proxyRequest.followRedirects = true;
|
|
||||||
|
|
||||||
if (request.method == 'POST') {
|
if (request.method == 'POST') {
|
||||||
final bodyBytes = await request.read().toList();
|
final bodyBytes = await request.read().toList();
|
||||||
proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList();
|
postBody = bodyBytes.expand((i) => i).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
// Redirections suivies MANUELLEMENT : chaque destination est
|
||||||
final response = await _client
|
// revalidée (hôte privé, allowlist de domaine). Avec
|
||||||
.send(proxyRequest)
|
// followRedirects, la validation ne portait que sur l'URL
|
||||||
.timeout(const Duration(seconds: 90));
|
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||||||
|
// un hôte interne malgré l'anti-SSRF.
|
||||||
|
http.StreamedResponse response;
|
||||||
|
var currentUrl = targetUrl;
|
||||||
|
var redirects = 0;
|
||||||
|
while (true) {
|
||||||
|
final proxyRequest = http.Request(request.method, currentUrl);
|
||||||
|
proxyRequest.headers.addAll(proxyHeaders);
|
||||||
|
proxyRequest.followRedirects = false;
|
||||||
|
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||||||
|
|
||||||
|
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||||
|
response = await _client
|
||||||
|
.send(proxyRequest)
|
||||||
|
.timeout(const Duration(seconds: 90));
|
||||||
|
|
||||||
|
final location = response.headers['location'];
|
||||||
|
final isRedirect = response.statusCode >= 300 &&
|
||||||
|
response.statusCode < 400 &&
|
||||||
|
location != null;
|
||||||
|
if (!isRedirect) break;
|
||||||
|
|
||||||
|
if (++redirects > 3) {
|
||||||
|
return Response.forbidden('Too many redirects');
|
||||||
|
}
|
||||||
|
final next = Uri.parse(location);
|
||||||
|
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||||||
|
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||||||
|
return Response.forbidden('Unsupported redirect scheme');
|
||||||
|
}
|
||||||
|
if (isForbiddenProxyHost(currentUrl.host)) {
|
||||||
|
print(
|
||||||
|
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||||||
|
);
|
||||||
|
return Response.forbidden('Access to this host is forbidden');
|
||||||
|
}
|
||||||
|
if (allowedHost != null &&
|
||||||
|
currentUrl.host.toLowerCase() != allowedHost) {
|
||||||
|
print(
|
||||||
|
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||||||
|
);
|
||||||
|
return Response.forbidden(
|
||||||
|
'Access to this domain is forbidden by policy',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build response headers from source response
|
// Build response headers from source response
|
||||||
final responseHeaders = <String, String>{
|
final responseHeaders = <String, String>{
|
||||||
@@ -221,7 +275,12 @@ class ProxyHandler {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ProxyHandler] error on $path: $e');
|
// Redaction : une ClientException porte l'URL amont, credentials
|
||||||
|
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||||||
|
print(
|
||||||
|
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||||||
|
'${LogRedactor.redactUrl('$e')}',
|
||||||
|
);
|
||||||
|
|
||||||
// Return transparent 1x1 pixel image fallback for images
|
// Return transparent 1x1 pixel image fallback for images
|
||||||
if (targetUrl?.path.endsWith('.png') == true ||
|
if (targetUrl?.path.endsWith('.png') == true ||
|
||||||
@@ -235,7 +294,7 @@ class ProxyHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}),
|
body: jsonEncode({'error': 'Proxy error'}),
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-60
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
|
import '../models/recording.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../services/recording_scheduler.dart';
|
import '../services/recording_scheduler.dart';
|
||||||
import '../utils/safe_path.dart';
|
import '../utils/safe_path.dart';
|
||||||
@@ -11,25 +12,43 @@ class RecordingsApi {
|
|||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final RecordingScheduler _scheduler;
|
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);
|
RecordingsApi(this._db, this._scheduler);
|
||||||
|
|
||||||
|
Response _json(int status, Map<String, dynamic> body) => Response(
|
||||||
|
status,
|
||||||
|
body: json.encode(body),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
|
||||||
|
/// (même patron de contrôle de propriété que playlists_handler)
|
||||||
|
bool _canAccess(User? user, Recording recording) {
|
||||||
|
if (user == null) return false;
|
||||||
|
return user.isAdmin || recording.userId == user.id;
|
||||||
|
}
|
||||||
|
|
||||||
/// Handler pour GET /api/recordings/logs/<id>
|
/// Handler pour GET /api/recordings/logs/<id>
|
||||||
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
||||||
Future<Response> getLogHandler(Request request, String id) async {
|
Future<Response> getLogHandler(Request request, String id) async {
|
||||||
final recording = _db.getRecordingById(id);
|
final recording = _db.getRecordingById(id);
|
||||||
|
|
||||||
if (recording == null) {
|
if (recording == null) {
|
||||||
return Response.notFound(
|
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
}
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
final user = request.context['user'] as User?;
|
||||||
|
if (!_canAccess(user, recording)) {
|
||||||
|
return _json(403, {'error': 'Accès refusé'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recording.filePath == null) {
|
if (recording.filePath == null) {
|
||||||
return Response.notFound(
|
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
|
||||||
json.encode({'error': 'Aucun fichier ni log associé pour le moment.'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les enregistrements sont écrits en .mkv avec un .log à côté
|
// Les enregistrements sont écrits en .mkv avec un .log à côté
|
||||||
@@ -39,55 +58,115 @@ class RecordingsApi {
|
|||||||
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
|
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
|
||||||
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
|
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
|
||||||
if (safeLogPath == null) {
|
if (safeLogPath == null) {
|
||||||
return Response.forbidden(
|
return _json(403, {'error': 'Chemin de log invalide'});
|
||||||
json.encode({'error': 'Chemin de log invalide'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final logFile = File(safeLogPath);
|
final logFile = File(safeLogPath);
|
||||||
|
|
||||||
if (!await logFile.exists()) {
|
if (!await logFile.exists()) {
|
||||||
return Response.notFound(
|
return _json(404, {'error': 'Le fichier de log est introuvable.'});
|
||||||
json.encode({'error': 'Le fichier de log est introuvable.'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final logs = await logFile.readAsString();
|
final logs = await logFile.readAsString();
|
||||||
return Response.ok(
|
return _json(200, {'logs': logs});
|
||||||
json.encode({'logs': logs}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||||
/// (tous les enregistrements pour un admin)
|
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||||
|
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||||
Response handleGetAll(Request request) {
|
Response handleGetAll(Request request) {
|
||||||
final user = request.context['user'] as User?;
|
final user = request.context['user'] as User?;
|
||||||
final recordings = (user != null && !user.isAdmin)
|
final recordings = (user != null && !user.isAdmin)
|
||||||
? _db.getUserRecordings(user.id)
|
? _db.getUserRecordings(user.id)
|
||||||
: _db.getAllRecordings();
|
: _db.getAllRecordings();
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
json.encode(recordings.map((r) => r.toMap()).toList()),
|
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||||
|
final map = r.toMap();
|
||||||
|
|
||||||
|
if (r.status == 'recording') {
|
||||||
|
final start = r.startTime.toUtc();
|
||||||
|
final end = r.endTime.toUtc();
|
||||||
|
final total = end.difference(start).inSeconds;
|
||||||
|
if (total > 0) {
|
||||||
|
final elapsed = now.difference(start).inSeconds;
|
||||||
|
map['progress_pct'] =
|
||||||
|
(elapsed * 100 / total).clamp(0, 100).round();
|
||||||
|
}
|
||||||
|
map['is_active'] = _scheduler.isCapturing(r.id);
|
||||||
|
final retries = _scheduler.retryCountOf(r.id);
|
||||||
|
if (retries != null) map['retry_count'] = retries;
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = r.filePath;
|
||||||
|
if (path != null) {
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/recordings — Planifie un nouvel enregistrement
|
/// POST /api/recordings — Planifie un nouvel enregistrement
|
||||||
Future<Response> handlePost(Request request) async {
|
Future<Response> handlePost(Request request) async {
|
||||||
try {
|
final user = request.context['user'] as User?;
|
||||||
final payload = await request.readAsString();
|
final userId = user?.id ?? request.context['userId'] as String?;
|
||||||
final data = json.decode(payload);
|
if (userId == null) {
|
||||||
|
return _json(401, {'error': 'Authentification requise'});
|
||||||
|
}
|
||||||
|
|
||||||
final userId = request.context['userId'] as String? ?? 'dev_user_id';
|
Map<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return _json(400, {'error': 'Corps JSON invalide'});
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelId = data['channel_id']?.toString() ?? '';
|
||||||
|
final streamUrl = data['stream_url']?.toString() ?? '';
|
||||||
|
if (channelId.isEmpty) {
|
||||||
|
return _json(400, {'error': 'channel_id est requis'});
|
||||||
|
}
|
||||||
|
if (streamUrl.isEmpty) {
|
||||||
|
return _json(400, {'error': 'stream_url est requis'});
|
||||||
|
}
|
||||||
|
|
||||||
|
final startTime = _parseZonedDate(data['start_time']);
|
||||||
|
final endTime = _parseZonedDate(data['end_time']);
|
||||||
|
if (startTime == null || endTime == null) {
|
||||||
|
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
|
||||||
|
// l'interpréter dans le fuseau du serveur décale l'enregistrement
|
||||||
|
// de plusieurs heures selon le TZ du conteneur.
|
||||||
|
return _json(400, {
|
||||||
|
'error':
|
||||||
|
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
|
||||||
|
'(ex: 2026-08-27T21:00:00Z)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!endTime.isAfter(startTime)) {
|
||||||
|
return _json(400, {'error': 'end_time doit être après start_time'});
|
||||||
|
}
|
||||||
|
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
|
||||||
|
return _json(400, {
|
||||||
|
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final recording = _db.createRecording(
|
final recording = _db.createRecording(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
channelId: data['channel_id'],
|
channelId: channelId,
|
||||||
streamUrl: data['stream_url'],
|
streamUrl: streamUrl,
|
||||||
title: data['title'] ?? 'Sans Titre',
|
title: data['title']?.toString() ?? 'Sans Titre',
|
||||||
startTime: DateTime.parse(data['start_time']),
|
startTime: startTime,
|
||||||
endTime: DateTime.parse(data['end_time']),
|
endTime: endTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
@@ -95,58 +174,87 @@ class RecordingsApi {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Response.internalServerError(
|
print('[RecordingsApi] Erreur à la création: $e');
|
||||||
body: json.encode({'error': 'Erreur lors de la programmation: $e'}),
|
return _json(500, {'error': 'Erreur lors de la programmation'});
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
|
||||||
|
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
|
||||||
|
DateTime? _parseZonedDate(dynamic raw) {
|
||||||
|
final str = raw?.toString() ?? '';
|
||||||
|
if (str.isEmpty) return null;
|
||||||
|
// 'Z' final ou offset ±hh[:mm] après l'heure
|
||||||
|
final hasZone =
|
||||||
|
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
|
||||||
|
if (!hasZone) return null;
|
||||||
|
return DateTime.tryParse(str)?.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
||||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression
|
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
|
||||||
|
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
|
||||||
Future<Response> handleDelete(Request request, String id) async {
|
Future<Response> handleDelete(Request request, String id) async {
|
||||||
final recording = _db.getRecordingById(id);
|
final recording = _db.getRecordingById(id);
|
||||||
|
|
||||||
if (recording == null) {
|
if (recording == null) {
|
||||||
return Response.notFound(
|
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
}
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
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
|
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
|
||||||
await _scheduler.stopRecording(id);
|
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);
|
_db.deleteRecording(id);
|
||||||
|
|
||||||
return Response.ok(
|
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
|
||||||
json.encode({'message': 'Enregistrement supprimé avec succès'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
||||||
Future<Response> handleStop(Request request, String id) async {
|
Future<Response> handleStop(Request request, String id) async {
|
||||||
final recording = _db.getRecordingById(id);
|
final recording = _db.getRecordingById(id);
|
||||||
if (recording == null) {
|
if (recording == null) {
|
||||||
return Response.notFound(
|
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||||
json.encode({'error': 'Enregistrement non trouvé'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final user = request.context['user'] as User?;
|
||||||
|
if (!_canAccess(user, recording)) {
|
||||||
|
return _json(403, {'error': 'Accès refusé'});
|
||||||
|
}
|
||||||
|
|
||||||
final stopped = await _scheduler.stopRecording(id);
|
final stopped = await _scheduler.stopRecording(id);
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
return Response.ok(
|
return _json(200, {'message': 'Enregistrement arrêté'});
|
||||||
json.encode({'message': 'Enregistrement arrêté'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Pas de processus FFmpeg actif pour cet ID → marquer comme complété quand même
|
|
||||||
_db.updateRecordingStatus(id, 'completed');
|
|
||||||
return Response.ok(
|
|
||||||
json.encode({'message': 'Enregistrement marqué comme terminé'}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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é'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,17 +9,29 @@ class SeasonPassesApi {
|
|||||||
|
|
||||||
SeasonPassesApi(this._db);
|
SeasonPassesApi(this._db);
|
||||||
|
|
||||||
/// GET /api/season-passes — liste tous les season passes
|
/// GET /api/season-passes — liste les season passes de l'utilisateur
|
||||||
|
/// (tous les passes pour un admin)
|
||||||
Response handleGetAll(Request request) {
|
Response handleGetAll(Request request) {
|
||||||
try {
|
try {
|
||||||
final passes = _db.getAllSeasonPasses();
|
final user = request.context['user'] as User?;
|
||||||
|
if (user == null) {
|
||||||
|
return Response(
|
||||||
|
401,
|
||||||
|
body: json.encode({'error': 'Authentification requise'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final passes = user.isAdmin
|
||||||
|
? _db.getAllSeasonPasses()
|
||||||
|
: _db.getSeasonPassesForUser(user.id);
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
json.encode(passes),
|
json.encode(passes),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[SeasonPass] Erreur au listage: $e');
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
body: json.encode({'error': 'Erreur: $e'}),
|
body: json.encode({'error': 'Erreur interne'}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,10 +71,20 @@ class SeasonPassesApi {
|
|||||||
|
|
||||||
// Récupérer l'utilisateur depuis le contexte
|
// Récupérer l'utilisateur depuis le contexte
|
||||||
final user = request.context['user'] as User?;
|
final user = request.context['user'] as User?;
|
||||||
final userId = user?.id ?? 'admin'; // fallback
|
if (user == null) {
|
||||||
|
return Response(
|
||||||
|
401,
|
||||||
|
body: json.encode({'error': 'Authentification requise'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier si un season pass identique existe déjà
|
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
||||||
final existing = _db.getAllSeasonPasses();
|
// 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(
|
final duplicate = existing.any(
|
||||||
(p) =>
|
(p) =>
|
||||||
(p['show_title'] as String).toLowerCase() ==
|
(p['show_title'] as String).toLowerCase() ==
|
||||||
@@ -78,10 +100,11 @@ class SeasonPassesApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final pass = _db.createSeasonPass(
|
final pass = _db.createSeasonPass(
|
||||||
userId: userId,
|
userId: user.id,
|
||||||
showTitle: showTitle,
|
showTitle: showTitle,
|
||||||
channelId: channelId,
|
channelId: channelId,
|
||||||
streamUrl: streamUrl,
|
streamUrl: streamUrl,
|
||||||
|
matchMode: matchMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||||
@@ -101,14 +124,30 @@ class SeasonPassesApi {
|
|||||||
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
||||||
Response handleDelete(Request request, String id) {
|
Response handleDelete(Request request, String id) {
|
||||||
try {
|
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);
|
_db.deleteSeasonPass(id);
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
json.encode({'message': 'Season Pass supprimé'}),
|
json.encode({'message': 'Season Pass supprimé'}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[SeasonPass] Erreur à la suppression: $e');
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
body: json.encode({'error': 'Erreur: $e'}),
|
body: json.encode({'error': 'Erreur interne'}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ class UsersHandler {
|
|||||||
}), headers: {'Content-Type': 'application/json'},);
|
}), 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) {
|
if (db.findUserByUsername(username) != null) {
|
||||||
return Response.badRequest(body: jsonEncode({
|
return Response.badRequest(body: jsonEncode({
|
||||||
'success': false,
|
'success': false,
|
||||||
@@ -103,6 +110,13 @@ class UsersHandler {
|
|||||||
}), headers: {'Content-Type': 'application/json'},);
|
}), 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);
|
db.updateUserPassword(id, password);
|
||||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||||
|
|
||||||
|
|||||||
+200
-21
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
@@ -23,10 +24,68 @@ class AppDatabase {
|
|||||||
|
|
||||||
_db = sqlite3.open(dbPath);
|
_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();
|
await _createTables();
|
||||||
|
_runMigrations();
|
||||||
print('Database initialized: $dbPath');
|
print('Database initialized: $dbPath');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrations de schéma pour les bases créées par une version antérieure.
|
||||||
|
///
|
||||||
|
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
|
||||||
|
/// existante : chaque colonne introduite après coup doit avoir sa migration.
|
||||||
|
/// Les migrations sont numérotées et rejouées uniquement si nécessaire.
|
||||||
|
void _runMigrations() {
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
final result = _db.select(
|
||||||
|
'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version',
|
||||||
|
);
|
||||||
|
var current = result.first['v'] as int;
|
||||||
|
|
||||||
|
final migrations = <int, void Function()>{
|
||||||
|
// v1 : colonne error_reason absente des bases d'avant son introduction.
|
||||||
|
1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'),
|
||||||
|
// v2 : mode de correspondance des season passes. 'contains' pour les
|
||||||
|
// lignes existantes (comportement historique) ; les nouvelles créations
|
||||||
|
// passent par l'API qui choisit 'exact' par défaut.
|
||||||
|
2: () => _addColumnIfMissing(
|
||||||
|
'season_passes',
|
||||||
|
'match_mode',
|
||||||
|
"TEXT NOT NULL DEFAULT 'contains'",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final entry in migrations.entries) {
|
||||||
|
if (entry.key <= current) continue;
|
||||||
|
entry.value();
|
||||||
|
_db.execute(
|
||||||
|
'INSERT INTO schema_version (version) VALUES (?)',
|
||||||
|
[entry.key],
|
||||||
|
);
|
||||||
|
current = entry.key;
|
||||||
|
print('[DB] Migration v${entry.key} appliquée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addColumnIfMissing(String table, String column, String definition) {
|
||||||
|
final columns = _db.select('PRAGMA table_info($table)');
|
||||||
|
final exists = columns.any((row) => row['name'] == column);
|
||||||
|
if (!exists) {
|
||||||
|
_db.execute('ALTER TABLE $table ADD COLUMN $column $definition');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create database tables
|
/// Create database tables
|
||||||
Future<void> _createTables() async {
|
Future<void> _createTables() async {
|
||||||
// Users table
|
// Users table
|
||||||
@@ -107,6 +166,7 @@ class AppDatabase {
|
|||||||
channel_id TEXT NOT NULL,
|
channel_id TEXT NOT NULL,
|
||||||
stream_url TEXT NOT NULL,
|
stream_url TEXT NOT NULL,
|
||||||
enabled INTEGER DEFAULT 1,
|
enabled INTEGER DEFAULT 1,
|
||||||
|
match_mode TEXT NOT NULL DEFAULT 'exact',
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
@@ -125,16 +185,32 @@ class AppDatabase {
|
|||||||
_db.execute(
|
_db.execute(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
|
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
|
||||||
);
|
);
|
||||||
|
_db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)',
|
||||||
|
);
|
||||||
|
_db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)',
|
||||||
|
);
|
||||||
|
_db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seed default admin user if no users exist
|
/// Seed default admin user if no users exist.
|
||||||
|
///
|
||||||
|
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
||||||
|
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
|
||||||
|
/// couple admin/admin restait souvent en place sur les instances exposées.
|
||||||
Future<void> seedAdmin() async {
|
Future<void> seedAdmin() async {
|
||||||
final result = _db.select('SELECT COUNT(*) as count FROM users');
|
final result = _db.select('SELECT COUNT(*) as count FROM users');
|
||||||
final count = result.first['count'] as int;
|
final count = result.first['count'] as int;
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
final adminId = _uuid.v4();
|
final adminId = _uuid.v4();
|
||||||
final passwordHash = PasswordHasher.hash('admin');
|
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
|
||||||
|
final generated = envPassword == null || envPassword.isEmpty;
|
||||||
|
final password = generated ? _generatePassword() : envPassword;
|
||||||
|
final passwordHash = PasswordHasher.hash(password);
|
||||||
|
|
||||||
_db.execute(
|
_db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -144,10 +220,34 @@ class AppDatabase {
|
|||||||
[adminId, 'admin', passwordHash],
|
[adminId, 'admin', passwordHash],
|
||||||
);
|
);
|
||||||
|
|
||||||
print('Default admin user created (username: admin, password: admin)');
|
if (generated) {
|
||||||
|
print('╔══════════════════════════════════════════════════════════╗');
|
||||||
|
print(' Compte admin créé — mot de passe initial (affiché une');
|
||||||
|
print(' seule fois, changez-le après la première connexion) :');
|
||||||
|
print(' utilisateur: admin');
|
||||||
|
print(' mot de passe: $password');
|
||||||
|
print('╚══════════════════════════════════════════════════════════╝');
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
'Default admin user created (username: admin, '
|
||||||
|
'password: ADMIN_INITIAL_PASSWORD)',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _generatePassword({int length = 16}) {
|
||||||
|
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
|
||||||
|
// depuis les logs du conteneur.
|
||||||
|
const chars =
|
||||||
|
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
final random = Random.secure();
|
||||||
|
return List.generate(
|
||||||
|
length,
|
||||||
|
(_) => chars[random.nextInt(chars.length)],
|
||||||
|
).join();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Users ====================
|
// ==================== Users ====================
|
||||||
|
|
||||||
/// Find user by username
|
/// Find user by username
|
||||||
@@ -473,6 +573,11 @@ class AppDatabase {
|
|||||||
final recordingId = _uuid.v4();
|
final recordingId = _uuid.v4();
|
||||||
final now = DateTime.now().toIso8601String();
|
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(
|
_db.execute(
|
||||||
'''
|
'''
|
||||||
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
|
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
|
||||||
@@ -484,8 +589,8 @@ class AppDatabase {
|
|||||||
channelId,
|
channelId,
|
||||||
streamUrl,
|
streamUrl,
|
||||||
title,
|
title,
|
||||||
startTime.toIso8601String(),
|
startUtc.toIso8601String(),
|
||||||
endTime.toIso8601String(),
|
endUtc.toIso8601String(),
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
@@ -497,21 +602,41 @@ class AppDatabase {
|
|||||||
channelId: channelId,
|
channelId: channelId,
|
||||||
streamUrl: streamUrl,
|
streamUrl: streamUrl,
|
||||||
title: title,
|
title: title,
|
||||||
startTime: startTime,
|
startTime: startUtc,
|
||||||
endTime: endTime,
|
endTime: endUtc,
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
createdAt: DateTime.parse(now),
|
createdAt: DateTime.parse(now),
|
||||||
updatedAt: DateTime.parse(now),
|
updatedAt: DateTime.parse(now),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
|
/// Lister tous les enregistrements (pour l'admin)
|
||||||
List<Recording> getAllRecordings() {
|
List<Recording> getAllRecordings() {
|
||||||
final result =
|
final result =
|
||||||
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||||
return result.map((row) => Recording.fromMap(row)).toList();
|
return result.map((row) => Recording.fromMap(row)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
|
||||||
|
/// Évite de désérialiser tout l'historique toutes les 10 secondes.
|
||||||
|
List<Recording> getPendingRecordings() {
|
||||||
|
final result = _db.select(
|
||||||
|
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') "
|
||||||
|
'ORDER BY start_time ASC',
|
||||||
|
);
|
||||||
|
return result.map((row) => Recording.fromMap(row)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistrements terminés (completed/failed/cancelled) du plus ancien au
|
||||||
|
/// plus récent — utilisé par la rotation disque.
|
||||||
|
List<Recording> getFinishedRecordingsOldestFirst() {
|
||||||
|
final result = _db.select(
|
||||||
|
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') "
|
||||||
|
'ORDER BY start_time ASC',
|
||||||
|
);
|
||||||
|
return result.map((row) => Recording.fromMap(row)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Lister les enregistrements d'un utilisateur spécifique
|
/// Lister les enregistrements d'un utilisateur spécifique
|
||||||
List<Recording> getUserRecordings(String userId) {
|
List<Recording> getUserRecordings(String userId) {
|
||||||
final result = _db.select(
|
final result = _db.select(
|
||||||
@@ -551,6 +676,24 @@ class AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé
|
||||||
|
/// dans l'EPG depuis sa création par un season pass).
|
||||||
|
void updateRecordingWindow(String id, DateTime start, DateTime end) {
|
||||||
|
_db.execute(
|
||||||
|
'''
|
||||||
|
UPDATE tv_recordings
|
||||||
|
SET start_time = ?, end_time = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
start.toUtc().toIso8601String(),
|
||||||
|
end.toUtc().toIso8601String(),
|
||||||
|
DateTime.now().toIso8601String(),
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
|
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
|
||||||
void deleteRecording(String id) {
|
void deleteRecording(String id) {
|
||||||
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
||||||
@@ -564,12 +707,13 @@ class AppDatabase {
|
|||||||
required String showTitle,
|
required String showTitle,
|
||||||
required String channelId,
|
required String channelId,
|
||||||
required String streamUrl,
|
required String streamUrl,
|
||||||
|
String matchMode = 'exact',
|
||||||
}) {
|
}) {
|
||||||
final id = _uuid.v4();
|
final id = _uuid.v4();
|
||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
_db.execute(
|
_db.execute(
|
||||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[id, userId, showTitle, channelId, streamUrl, now],
|
[id, userId, showTitle, channelId, streamUrl, matchMode, now],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
@@ -577,12 +721,13 @@ class AppDatabase {
|
|||||||
'show_title': showTitle,
|
'show_title': showTitle,
|
||||||
'channel_id': channelId,
|
'channel_id': channelId,
|
||||||
'stream_url': streamUrl,
|
'stream_url': streamUrl,
|
||||||
|
'match_mode': matchMode,
|
||||||
'enabled': 1,
|
'enabled': 1,
|
||||||
'created_at': now,
|
'created_at': now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lister tous les Season Passes
|
/// Lister les Season Passes actifs (pour le scheduler)
|
||||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||||
final result = _db.select(
|
final result = _db.select(
|
||||||
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
||||||
@@ -590,23 +735,57 @@ class AppDatabase {
|
|||||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
|
||||||
|
List<Map<String, dynamic>> getSeasonPassesForUser(String userId) {
|
||||||
|
final result = _db.select(
|
||||||
|
'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
|
||||||
|
Map<String, dynamic>? getSeasonPassById(String id) {
|
||||||
|
final result =
|
||||||
|
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
|
||||||
|
if (result.isEmpty) return null;
|
||||||
|
return Map<String, dynamic>.from(result.first);
|
||||||
|
}
|
||||||
|
|
||||||
/// Supprimer un Season Pass
|
/// Supprimer un Season Pass
|
||||||
void deleteSeasonPass(String id) {
|
void deleteSeasonPass(String id) {
|
||||||
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
|
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifier si un enregistrement existe déjà pour ce titre (déduplication)
|
/// Cherche un enregistrement existant pour cet épisode (déduplication des
|
||||||
/// Retourne true si un enregistrement non-échoué avec ce titre existe pour cetteémission programméeà la même heure
|
/// season passes) : même titre, début à ±[tolerance] près.
|
||||||
bool existsRecordingForEpisode(String title, DateTime startTime) {
|
///
|
||||||
// Normaliser le titre pour la comparaison (insensible casse, sans espaces doubles)
|
/// 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(
|
final result = _db.select(
|
||||||
'''SELECT COUNT(*) as cnt FROM tv_recordings
|
'''SELECT * FROM tv_recordings
|
||||||
WHERE LOWER(title) = LOWER(?)
|
WHERE LOWER(title) = LOWER(?)
|
||||||
AND start_time = ?
|
AND start_time BETWEEN ? AND ?
|
||||||
AND status NOT IN ('failed')''',
|
AND status NOT IN ('failed', 'cancelled')
|
||||||
[title, startTime.toUtc().toIso8601String()],
|
LIMIT 1''',
|
||||||
|
[
|
||||||
|
title,
|
||||||
|
startUtc.subtract(tolerance).toIso8601String(),
|
||||||
|
startUtc.add(tolerance).toIso8601String(),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return (result.first['cnt'] as int) > 0;
|
if (result.isEmpty) return null;
|
||||||
|
return Recording.fromMap(result.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close database connection
|
/// Close database connection
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ Middleware streamAuthMiddleware(AppDatabase db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract token from Authorization header or cookie
|
/// Extract token from Authorization header or cookie.
|
||||||
|
/// Public : le proxy /api/xtream fait sa propre vérification de session
|
||||||
|
/// (le contrôle doit rester DANS le handler, après le test de chemin,
|
||||||
|
/// pour que les requêtes non-proxy tombent sur le handler statique).
|
||||||
|
String? extractAuthToken(Request request) => _extractToken(request);
|
||||||
|
|
||||||
String? _extractToken(Request request) {
|
String? _extractToken(Request request) {
|
||||||
// Try Authorization header first
|
// Try Authorization header first
|
||||||
final authHeader = request.headers['authorization'];
|
final authHeader = request.headers['authorization'];
|
||||||
|
|||||||
@@ -1,26 +1,94 @@
|
|||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import '../utils/log_redactor.dart';
|
||||||
|
|
||||||
/// Security Middleware Collection
|
/// Security Middleware Collection
|
||||||
///
|
///
|
||||||
/// Includes:
|
/// Includes:
|
||||||
|
/// - Redacted request logging
|
||||||
/// - Honeypot Routes (Trap for bots)
|
/// - Honeypot Routes (Trap for bots)
|
||||||
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
|
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
|
||||||
/// - Rate Limiting (Basic DoS protection)
|
/// - Rate Limiting (Basic DoS protection)
|
||||||
/// - Login-specific rate limiting (brute-force protection)
|
/// - Login-specific rate limiting (brute-force protection)
|
||||||
|
|
||||||
/// Resolve the real client IP.
|
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
|
||||||
/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise
|
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
|
||||||
/// falls back to the socket connection info.
|
/// docker-compose parle depuis le réseau Docker). Surcharger avec
|
||||||
String clientIpOf(Request request) {
|
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
|
||||||
final forwarded = request.headers['x-forwarded-for'];
|
final List<String> _trustedProxies =
|
||||||
if (forwarded != null && forwarded.isNotEmpty) {
|
(Platform.environment['TRUSTED_PROXIES'] ?? '')
|
||||||
return forwarded.split(',').first.trim();
|
.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 =
|
final connectionInfo =
|
||||||
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
||||||
return connectionInfo?.remoteAddress.address ?? 'unknown';
|
final socketAddress = connectionInfo?.remoteAddress.address;
|
||||||
|
|
||||||
|
final forwarded = request.headers['x-forwarded-for'];
|
||||||
|
if (forwarded != null &&
|
||||||
|
forwarded.isNotEmpty &&
|
||||||
|
socketAddress != null &&
|
||||||
|
_isTrustedProxy(socketAddress)) {
|
||||||
|
return forwarded.split(',').first.trim();
|
||||||
|
}
|
||||||
|
return socketAddress ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0. Redacted request logging.
|
||||||
|
///
|
||||||
|
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
|
||||||
|
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
|
||||||
|
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
|
||||||
|
Middleware redactedLogRequests() {
|
||||||
|
return (Handler handler) {
|
||||||
|
return (Request request) async {
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final response = await handler(request);
|
||||||
|
watch.stop();
|
||||||
|
final query =
|
||||||
|
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
|
||||||
|
print(
|
||||||
|
'${DateTime.now().toIso8601String()} ${response.statusCode} '
|
||||||
|
'${request.method} '
|
||||||
|
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
|
||||||
|
'(${watch.elapsedMilliseconds}ms)',
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
watch.stop();
|
||||||
|
print(
|
||||||
|
'${DateTime.now().toIso8601String()} ERR ${request.method} '
|
||||||
|
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
|
||||||
|
'${LogRedactor.redactUrl('$e')}',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1. Security Headers Middleware
|
/// 1. Security Headers Middleware
|
||||||
@@ -71,11 +139,14 @@ Middleware honeypotMiddleware() {
|
|||||||
|
|
||||||
return (Handler handler) {
|
return (Handler handler) {
|
||||||
return (Request request) {
|
return (Request request) {
|
||||||
final path = request.url.path;
|
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
|
||||||
|
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
|
||||||
|
// contenant « console », « env » ou « wpadmin » n'importe où — y
|
||||||
|
// compris des URLs proxifiées parfaitement légitimes.
|
||||||
|
final path = '/${request.url.path}';
|
||||||
|
|
||||||
// Check if path contains any honeypot target
|
|
||||||
for (final trap in honeypotPaths) {
|
for (final trap in honeypotPaths) {
|
||||||
if (path.contains(trap.replaceAll('/', ''))) { // Simple check
|
if (path == trap || path.startsWith('$trap/')) {
|
||||||
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
|
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
|
||||||
return Response.forbidden('Access Denied');
|
return Response.forbidden('Access Denied');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "105.0.0"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.1.0"
|
||||||
|
args:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
bcrypt:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: bcrypt
|
||||||
|
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
cli_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_config
|
||||||
|
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.0"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.1"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
equatable:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
frontend_server_client:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: frontend_server_client
|
||||||
|
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
hive:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: hive
|
||||||
|
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.3"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_methods:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_methods
|
||||||
|
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.20"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
node_preamble:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: node_preamble
|
||||||
|
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
shelf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_packages_handler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_packages_handler
|
||||||
|
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
shelf_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_router
|
||||||
|
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
|
shelf_static:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_static
|
||||||
|
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.3"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
source_map_stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_map_stack_trace
|
||||||
|
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
source_maps:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_maps
|
||||||
|
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.14"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
sqlite3:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: test
|
||||||
|
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.31.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.13"
|
||||||
|
test_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_core
|
||||||
|
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.19"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.3.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
|
webkit_inspection_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webkit_inspection_protocol
|
||||||
|
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
xml:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.11.0 <4.0.0"
|
||||||
+32
-22
@@ -42,31 +42,37 @@ void main(List<String> args) async {
|
|||||||
await db.seedAdmin();
|
await db.seedAdmin();
|
||||||
|
|
||||||
// Initialize and start Recording Scheduler
|
// 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);
|
final recordingScheduler = RecordingScheduler(db);
|
||||||
recordingScheduler.start();
|
recordingScheduler.start();
|
||||||
|
|
||||||
// Injecter la config playlist dans le scheduler pour les Season Passes
|
|
||||||
// (on prend la playlist du premier utilisateur disponible)
|
|
||||||
Future<void> injectPlaylistToScheduler() async {
|
|
||||||
final users = db.getAllUsers();
|
|
||||||
if (users.isNotEmpty) {
|
|
||||||
final playlists = db.getPlaylists(users[0].id);
|
|
||||||
if (playlists.isNotEmpty) {
|
|
||||||
final p = playlists.first;
|
|
||||||
recordingScheduler.playlistDns = p.serverUrl;
|
|
||||||
recordingScheduler.playlistUsername = p.username;
|
|
||||||
recordingScheduler.playlistPassword = p.password;
|
|
||||||
print('[Server] Playlist injectée dans le scheduler: ${p.name}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Injecter après 5s pour attendre l'initialisation complète
|
|
||||||
Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
|
|
||||||
|
|
||||||
// Initialize Streaming Subsystem
|
// Initialize Streaming Subsystem
|
||||||
await initStreaming();
|
await initStreaming();
|
||||||
|
|
||||||
|
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
|
||||||
|
// enregistrements (kill FFmpeg, fusion des parties, statut en base), puis
|
||||||
|
// les sessions de streaming, puis sortir. Sans cela les enregistrements
|
||||||
|
// restaient au statut « recording » et la reprise d'orphelins devait
|
||||||
|
// systématiquement rattraper au redémarrage.
|
||||||
|
var shuttingDown = false;
|
||||||
|
Future<void> shutdownServer(String signal) async {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
print('[Server] $signal reçu, arrêt en cours…');
|
||||||
|
try {
|
||||||
|
await recordingScheduler.shutdown();
|
||||||
|
} catch (e) {
|
||||||
|
print('[Server] Erreur à l\'arrêt du scheduler: $e');
|
||||||
|
}
|
||||||
|
sessionManager.killAll();
|
||||||
|
db.close();
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessSignal.sigterm.watch().listen((_) => shutdownServer('SIGTERM'));
|
||||||
|
ProcessSignal.sigint.watch().listen((_) => shutdownServer('SIGINT'));
|
||||||
|
|
||||||
// Helper to get playlist from request
|
// Helper to get playlist from request
|
||||||
Future<PlaylistConfig?> getPlaylist(Request request) async {
|
Future<PlaylistConfig?> getPlaylist(Request request) async {
|
||||||
Playlist? playlist;
|
Playlist? playlist;
|
||||||
@@ -114,7 +120,7 @@ void main(List<String> args) async {
|
|||||||
final playlistsHandler = PlaylistsHandler(db);
|
final playlistsHandler = PlaylistsHandler(db);
|
||||||
final usersHandler = UsersHandler(db);
|
final usersHandler = UsersHandler(db);
|
||||||
final settingsHandler = SettingsHandler(db);
|
final settingsHandler = SettingsHandler(db);
|
||||||
final proxyHandler = ProxyHandler(getPlaylist);
|
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||||
// l'EPG se limite alors au panneau de l'abonné.
|
// l'EPG se limite alors au panneau de l'abonné.
|
||||||
@@ -216,8 +222,10 @@ void main(List<String> args) async {
|
|||||||
// Do NOT mount here as it would intercept and block the actual proxy
|
// Do NOT mount here as it would intercept and block the actual proxy
|
||||||
|
|
||||||
// Initialize Cleanup Service
|
// 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();
|
final cleanupService = CleanupService();
|
||||||
cleanupService.addTarget(Directory.systemTemp);
|
|
||||||
cleanupService.addTarget(Directory('/app/data/logs'));
|
cleanupService.addTarget(Directory('/app/data/logs'));
|
||||||
cleanupService.addTarget(Directory('/app/data/tmp'));
|
cleanupService.addTarget(Directory('/app/data/tmp'));
|
||||||
|
|
||||||
@@ -332,8 +340,10 @@ void main(List<String> args) async {
|
|||||||
.handler;
|
.handler;
|
||||||
|
|
||||||
// Add middleware
|
// Add middleware
|
||||||
|
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
|
||||||
|
// contient username/password Xtream en clair.
|
||||||
final pipeline = const Pipeline()
|
final pipeline = const Pipeline()
|
||||||
.addMiddleware(logRequests())
|
.addMiddleware(redactedLogRequests())
|
||||||
.addMiddleware(securityHeadersMiddleware())
|
.addMiddleware(securityHeadersMiddleware())
|
||||||
.addMiddleware(honeypotMiddleware())
|
.addMiddleware(honeypotMiddleware())
|
||||||
.addMiddleware(rateLimitMiddleware())
|
.addMiddleware(rateLimitMiddleware())
|
||||||
|
|||||||
@@ -59,15 +59,10 @@ class FfmpegSessionManager {
|
|||||||
|
|
||||||
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
|
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
|
||||||
|
|
||||||
// Clean shutdown for docker stop / Ctrl+C
|
// L'arrêt propre (docker stop / Ctrl+C) est orchestré par server.dart :
|
||||||
ProcessSignal.sigterm.watch().listen((_) {
|
// il clôture d'abord les enregistrements puis appelle killAll(). Un
|
||||||
killAll();
|
// handler local qui ferait exit(0) immédiatement court-circuiterait
|
||||||
exit(0);
|
// cette clôture.
|
||||||
});
|
|
||||||
ProcessSignal.sigint.watch().listen((_) {
|
|
||||||
killAll();
|
|
||||||
exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FfmpegSession? get(String id) => _sessions[id];
|
FfmpegSession? get(String id) => _sessions[id];
|
||||||
|
|||||||
@@ -127,14 +127,34 @@ class RecordingScheduler {
|
|||||||
) ??
|
) ??
|
||||||
2;
|
2;
|
||||||
|
|
||||||
// Playlist config pour les appels EPG des season passes
|
/// Espace libre minimal (Mo) exigé pour démarrer une capture.
|
||||||
// Rempli depuis server.dart après initialisation
|
final int minFreeDiskMb = int.tryParse(
|
||||||
String? playlistDns;
|
Platform.environment['MIN_FREE_DISK_MB'] ?? '',
|
||||||
String? playlistUsername;
|
) ??
|
||||||
String? playlistPassword;
|
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);
|
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() {
|
void start() {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
|
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
|
||||||
@@ -163,6 +183,31 @@ class RecordingScheduler {
|
|||||||
print('[RecordingScheduler] Arrêté');
|
print('[RecordingScheduler] Arrêté');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Arrêt gracieux pour un `docker stop` : arrête les timers, clôt chaque
|
||||||
|
/// enregistrement actif (kill FFmpeg, fusion des parties, statut en base)
|
||||||
|
/// et attend la fin des clôtures dans la limite de [timeout].
|
||||||
|
///
|
||||||
|
/// Sans cette attente, le conteneur meurt avant la clôture : les
|
||||||
|
/// enregistrements restent au statut « recording » et la reprise d'orphelins
|
||||||
|
/// doit systématiquement rattraper au redémarrage.
|
||||||
|
Future<void> shutdown({Duration timeout = const Duration(seconds: 8)}) async {
|
||||||
|
_timer?.cancel();
|
||||||
|
_seasonPassTimer?.cancel();
|
||||||
|
final closings = <Future<void>>[];
|
||||||
|
for (final id in _active.keys.toList()) {
|
||||||
|
final future =
|
||||||
|
_stopActiveRecording(id, reason: 'Arrêt du serveur');
|
||||||
|
if (future != null) closings.add(future);
|
||||||
|
}
|
||||||
|
if (closings.isNotEmpty) {
|
||||||
|
print(
|
||||||
|
'[RecordingScheduler] Clôture de ${closings.length} enregistrement(s)…',
|
||||||
|
);
|
||||||
|
await Future.wait(closings).timeout(timeout, onTimeout: () => const []);
|
||||||
|
}
|
||||||
|
print('[RecordingScheduler] Arrêté proprement');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAndRunRecordings() async {
|
Future<void> _checkAndRunRecordings() async {
|
||||||
if (_isRunning) return;
|
if (_isRunning) return;
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
@@ -185,7 +230,7 @@ class RecordingScheduler {
|
|||||||
// Lire la base APRÈS les arrêts : un instantané pris avant ferait passer
|
// 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
|
// l'enregistrement tout juste terminé pour un orphelin (il n'est plus
|
||||||
// dans `_active` alors que l'instantané le dit encore « recording »).
|
// dans `_active` alors que l'instantané le dit encore « recording »).
|
||||||
final recordings = _db.getAllRecordings();
|
final recordings = _db.getPendingRecordings();
|
||||||
|
|
||||||
// Rechercher les enregistrements planifiés
|
// Rechercher les enregistrements planifiés
|
||||||
for (final recording in recordings) {
|
for (final recording in recordings) {
|
||||||
@@ -286,21 +331,22 @@ class RecordingScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalise un titre pour la correspondance : minuscules, espaces réduits.
|
||||||
|
static String _normalizeTitle(String title) =>
|
||||||
|
title.toLowerCase().trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
|
||||||
|
/// Le titre EPG [title] correspond-il au pass selon son [matchMode] ?
|
||||||
|
static bool _titleMatches(String title, String showTitle, String matchMode) {
|
||||||
|
final t = _normalizeTitle(title);
|
||||||
|
final s = _normalizeTitle(showTitle);
|
||||||
|
if (s.isEmpty) return false;
|
||||||
|
return matchMode == 'contains' ? t.contains(s) : t == s;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkSeasonPasses() async {
|
Future<void> _checkSeasonPasses() async {
|
||||||
final passes = _db.getAllSeasonPasses();
|
final passes = _db.getAllSeasonPasses();
|
||||||
if (passes.isEmpty) return;
|
if (passes.isEmpty) return;
|
||||||
|
|
||||||
final dns = playlistDns;
|
|
||||||
final username = playlistUsername;
|
|
||||||
final password = playlistPassword;
|
|
||||||
|
|
||||||
if (dns == null || username == null || password == null) {
|
|
||||||
print(
|
|
||||||
'[SeasonPass] Config playlist non disponible, vérification annulée',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
|
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
|
||||||
|
|
||||||
for (final pass in passes) {
|
for (final pass in passes) {
|
||||||
@@ -309,9 +355,28 @@ class RecordingScheduler {
|
|||||||
final streamUrl = pass['stream_url'] as String;
|
final streamUrl = pass['stream_url'] as String;
|
||||||
final showTitle = pass['show_title'] as String;
|
final showTitle = pass['show_title'] as String;
|
||||||
final userId = pass['user_id'] as String;
|
final userId = pass['user_id'] as String;
|
||||||
|
final matchMode = pass['match_mode'] as String? ?? 'contains';
|
||||||
|
|
||||||
// Récupérer l'EPG de la chaîne (48 prochaines heures)
|
// Résoudre la playlist du PROPRIÉTAIRE du pass au moment du scan.
|
||||||
final url = '$dns/player_api.php?username=$username&password=$password'
|
// 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';
|
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||||
|
|
||||||
final response =
|
final response =
|
||||||
@@ -322,14 +387,14 @@ class RecordingScheduler {
|
|||||||
final listings =
|
final listings =
|
||||||
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
var createdThisScan = 0;
|
||||||
for (final item in listings) {
|
for (final item in listings) {
|
||||||
String title = item['title'] as String? ?? '';
|
String title = item['title'] as String? ?? '';
|
||||||
try {
|
try {
|
||||||
title = utf8.decode(base64Decode(title));
|
title = utf8.decode(base64Decode(title));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
|
if (!_titleMatches(title, showTitle, matchMode)) continue;
|
||||||
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
|
|
||||||
|
|
||||||
// Parser les heures de début/fin
|
// Parser les heures de début/fin
|
||||||
final startStr = item['start'] as String? ?? '';
|
final startStr = item['start'] as String? ?? '';
|
||||||
@@ -348,12 +413,31 @@ class RecordingScheduler {
|
|||||||
// Ne pas créer pour les programmes déjà terminés
|
// Ne pas créer pour les programmes déjà terminés
|
||||||
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
||||||
|
|
||||||
// Déduplication : vérifier si cet épisode est déjà planifié/enregistré
|
// Déduplication : cet épisode est-il déjà planifié/enregistré ?
|
||||||
if (_db.existsRecordingForEpisode(title, startTime)) {
|
final existing = _db.findRecordingForEpisode(title, startTime);
|
||||||
print('[SeasonPass] "$title" déjà enregistré, skip.');
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createdThisScan >= seasonPassMaxPerScan) {
|
||||||
|
print(
|
||||||
|
'[SeasonPass] Plafond de $seasonPassMaxPerScan créations atteint '
|
||||||
|
'pour "$showTitle" sur ce scan',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Créer l'enregistrement automatiquement
|
// Créer l'enregistrement automatiquement
|
||||||
_db.createRecording(
|
_db.createRecording(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -363,12 +447,18 @@ class RecordingScheduler {
|
|||||||
startTime: startTime,
|
startTime: startTime,
|
||||||
endTime: endTime,
|
endTime: endTime,
|
||||||
);
|
);
|
||||||
|
createdThisScan++;
|
||||||
print(
|
print(
|
||||||
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
|
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
// Ne jamais imprimer l'exception brute : une ClientException peut
|
||||||
|
// contenir l'URL amont avec les credentials Xtream.
|
||||||
|
print(
|
||||||
|
'[SeasonPass] Erreur pour le pass "${pass['show_title']}": '
|
||||||
|
'${LogRedactor.redactUrl('$e')}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,6 +482,24 @@ class RecordingScheduler {
|
|||||||
// Nettoyer l'espace disque si nécessaire
|
// Nettoyer l'espace disque si nécessaire
|
||||||
await _checkDiskSpaceAndRotate(recordingsDir);
|
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 filePath = p.join(recordingsDir.path, _fileNameFor(recording));
|
||||||
final logPath = p.setExtension(filePath, '.log');
|
final logPath = p.setExtension(filePath, '.log');
|
||||||
|
|
||||||
@@ -673,9 +781,11 @@ class RecordingScheduler {
|
|||||||
return false; // Pas d'enregistrement actif avec cet ID
|
return false; // Pas d'enregistrement actif avec cet ID
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopActiveRecording(String id, {String? reason}) {
|
/// Arrête l'enregistrement actif [id] et retourne la future de clôture
|
||||||
|
/// (fusion des parties + statut), ou null si aucun n'est actif.
|
||||||
|
Future<void>? _stopActiveRecording(String id, {String? reason}) {
|
||||||
final active = _active.remove(id);
|
final active = _active.remove(id);
|
||||||
if (active == null) return;
|
if (active == null) return null;
|
||||||
active.stopping = true;
|
active.stopping = true;
|
||||||
if (reason != null) {
|
if (reason != null) {
|
||||||
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
|
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
|
||||||
@@ -685,7 +795,9 @@ class RecordingScheduler {
|
|||||||
);
|
);
|
||||||
active.process?.kill(ProcessSignal.sigterm);
|
active.process?.kill(ProcessSignal.sigterm);
|
||||||
_db.updateRecordingStatus(id, 'completed');
|
_db.updateRecordingStatus(id, 'completed');
|
||||||
unawaited(_finalizeStopped(active));
|
final closing = _finalizeStopped(active);
|
||||||
|
unawaited(closing);
|
||||||
|
return closing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
|
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
|
||||||
@@ -709,7 +821,9 @@ class RecordingScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _fileNameFor(Recording recording) {
|
String _fileNameFor(Recording recording) {
|
||||||
// Génération d'un nom de fichier unique et sûr
|
// Génération d'un nom de fichier unique et sûr. Le fragment d'id garantit
|
||||||
|
// l'unicité : deux utilisateurs enregistrant le même programme sur la même
|
||||||
|
// chaîne s'écrasaient mutuellement (FFmpeg est lancé avec -y).
|
||||||
final safeTitle =
|
final safeTitle =
|
||||||
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||||
final dateStr = recording.startTime
|
final dateStr = recording.startTime
|
||||||
@@ -717,7 +831,10 @@ class RecordingScheduler {
|
|||||||
.toIso8601String()
|
.toIso8601String()
|
||||||
.replaceAll(':', '')
|
.replaceAll(':', '')
|
||||||
.split('.')[0];
|
.split('.')[0];
|
||||||
return '${safeTitle}_$dateStr.mkv';
|
final idFragment = recording.id.length >= 8
|
||||||
|
? recording.id.substring(0, 8)
|
||||||
|
: recording.id;
|
||||||
|
return '${safeTitle}_${dateStr}_$idFragment.mkv';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
|
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
|
||||||
@@ -755,28 +872,87 @@ class RecordingScheduler {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
/// Espace libre (octets) sur le volume qui porte [path], ou null si `df`
|
||||||
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
|
/// n'est pas disponible.
|
||||||
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
|
Future<int?> _freeDiskBytes(String path) async {
|
||||||
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
|
|
||||||
try {
|
try {
|
||||||
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
final result = await Process.run('df', ['-B1', '--output=avail', path]);
|
||||||
final files = dir.listSync().whereType<File>().toList();
|
if (result.exitCode != 0) return null;
|
||||||
|
final lines = (result.stdout as String).trim().split('\n');
|
||||||
|
return int.tryParse(lines.last.trim());
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > maxFiles) {
|
/// Fichiers sur disque associés à un enregistrement : fichier principal,
|
||||||
print(
|
/// log, et parties issues des relances.
|
||||||
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements',
|
List<String> filesFor(String filePath) {
|
||||||
);
|
final paths = <String>[filePath, p.setExtension(filePath, '.log')];
|
||||||
files.sort(
|
for (var attempt = 1;; attempt++) {
|
||||||
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
|
final part = _partPath(filePath, attempt);
|
||||||
); // Du plus vieux au plus récent
|
if (!File(part).existsSync()) break;
|
||||||
|
paths.add(part);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
// Supprimer les plus anciens pour revenir sous la limite
|
/// Supprime les fichiers d'un enregistrement (appelé par l'API à la
|
||||||
final filesToDelete = files.take(files.length - maxFiles);
|
/// suppression, et par la rotation disque).
|
||||||
for (var file in filesToDelete) {
|
Future<void> deleteRecordingFiles(String filePath) async {
|
||||||
file.deleteSync();
|
for (final path in filesFor(filePath)) {
|
||||||
|
await _deleteQuietly(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotation par quota d'octets (env RECORDINGS_QUOTA_GB, 0 = désactivée).
|
||||||
|
///
|
||||||
|
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait les plus
|
||||||
|
/// anciens fichiers du dossier sans distinction : elle pouvait effacer une
|
||||||
|
/// partie en cours d'écriture par FFmpeg et laissait en base des lignes
|
||||||
|
/// pointant vers des fichiers disparus. Ici on ne supprime que des
|
||||||
|
/// enregistrements TERMINÉS connus de la base, du plus ancien au plus
|
||||||
|
/// récent, fichiers et ligne BDD ensemble, jamais un enregistrement actif.
|
||||||
|
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
||||||
|
if (recordingsQuotaGb <= 0) return;
|
||||||
|
try {
|
||||||
|
final quotaBytes = recordingsQuotaGb * 1024 * 1024 * 1024;
|
||||||
|
var totalBytes = 0;
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File) {
|
||||||
|
try {
|
||||||
|
totalBytes += await entity.length();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (totalBytes <= quotaBytes) return;
|
||||||
|
|
||||||
|
print(
|
||||||
|
'[RecordingScheduler] Quota disque dépassé '
|
||||||
|
'(${totalBytes ~/ (1024 * 1024)} Mo > $recordingsQuotaGb Go) : '
|
||||||
|
'rotation des enregistrements terminés les plus anciens',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final old in _db.getFinishedRecordingsOldestFirst()) {
|
||||||
|
if (totalBytes <= quotaBytes) break;
|
||||||
|
final path = old.filePath;
|
||||||
|
if (path == null) continue;
|
||||||
|
// Jamais un enregistrement encore capturé (statut périmé en base).
|
||||||
|
if (_active.containsKey(old.id)) continue;
|
||||||
|
var freed = 0;
|
||||||
|
for (final f in filesFor(path)) {
|
||||||
|
try {
|
||||||
|
freed += File(f).existsSync() ? File(f).lengthSync() : 0;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await deleteRecordingFiles(path);
|
||||||
|
_db.deleteRecording(old.id);
|
||||||
|
totalBytes -= freed;
|
||||||
|
print(
|
||||||
|
'[RecordingScheduler] Rotation : "${old.title}" supprimé '
|
||||||
|
'(${freed ~/ (1024 * 1024)} Mo libérés)',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
print(
|
||||||
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
|
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
|
||||||
|
|||||||
+4
-1
@@ -13,8 +13,11 @@ services:
|
|||||||
# Pour changer: modifiez le chemin avant les deux points ":"
|
# Pour changer: modifiez le chemin avant les deux points ":"
|
||||||
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
||||||
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
||||||
- ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings
|
- ${RECORDINGS_PATH:-./data/recordings}:/app/recordings
|
||||||
environment:
|
environment:
|
||||||
|
# Fuseau horaire du conteneur (affichage des logs ; les enregistrements
|
||||||
|
# sont stockés en UTC quoi qu'il arrive)
|
||||||
|
- TZ=${TZ:-Europe/Paris}
|
||||||
# Origine externe autorisée pour CORS (optionnel — l'app est servie
|
# Origine externe autorisée pour CORS (optionnel — l'app est servie
|
||||||
# same-origin, ne définir que si un autre domaine doit appeler l'API)
|
# same-origin, ne définir que si un autre domaine doit appeler l'API)
|
||||||
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
|
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,12 @@
|
|||||||
|
# Archives — documents historiques
|
||||||
|
|
||||||
|
⚠️ **Les documents de ce dossier sont historiques et ne décrivent pas l'état actuel du code.**
|
||||||
|
|
||||||
|
Ils datent de phases de conception ou de refontes antérieures. Plusieurs se déclarent « COMPLETE » ou « production-ready » alors que le travail décrit n'a jamais été intégré (ou a été remplacé depuis) :
|
||||||
|
|
||||||
|
- `RECORDING_SYSTEM_UPGRADE.txt` — décrit un remplacement du scheduler par `SimpleRecorder` qui n'a jamais été branché ; le système réel est `bin/services/recording_scheduler.dart`.
|
||||||
|
- `THEME_REDESIGN_SUMMARY.md` — la « Phase 4: Integration » n'a pas eu lieu ; les widgets qu'il liste n'existent plus.
|
||||||
|
- `ANALYSIS_AND_IMPROVEMENTS.md` — plan d'améliorations dont une partie seulement a été réalisée.
|
||||||
|
- `DEPLOYMENT_CHECKLIST.md` — référence des fichiers et versions de dépendances qui n'ont jamais existé dans le dépôt.
|
||||||
|
|
||||||
|
Pour l'état actuel du projet, se référer au `README.md` racine et au `CHANGELOG.md`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'authed_http.dart';
|
||||||
|
|
||||||
|
/// Point d'entrée unique pour créer un enregistrement via POST /api/recordings.
|
||||||
|
///
|
||||||
|
/// Le backend exige désormais des dates ISO-8601 AVEC fuseau (suffixe 'Z' ou
|
||||||
|
/// offset) et rejette les dates naïves en 400 : trois conventions d'envoi
|
||||||
|
/// coexistaient dans l'app (UTC, local naïf, UTC naïf), d'où des
|
||||||
|
/// enregistrements décalés de 1-2 h selon l'écran utilisé.
|
||||||
|
///
|
||||||
|
/// [wallClockIsUtc] : les horaires issus de l'EPG sont des heures UTC
|
||||||
|
/// « naïves » (parsées sans fuseau par Dart mais comparées à `now.toUtc()`
|
||||||
|
/// partout dans l'app). true les re-tague en UTC sans décalage ; false (par
|
||||||
|
/// défaut) convertit depuis l'heure locale réelle (cas d'un DateTime.now()).
|
||||||
|
Future<http.Response> postRecording({
|
||||||
|
required String channelId,
|
||||||
|
required String title,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
String? streamUrl,
|
||||||
|
bool wallClockIsUtc = false,
|
||||||
|
}) {
|
||||||
|
DateTime asUtc(DateTime d) {
|
||||||
|
if (d.isUtc) return d;
|
||||||
|
return wallClockIsUtc
|
||||||
|
? DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second)
|
||||||
|
: d.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthedHttp.post(
|
||||||
|
Uri.parse('/api/recordings'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({
|
||||||
|
'channel_id': channelId,
|
||||||
|
'stream_url': streamUrl ?? '/api/live/$channelId.ts',
|
||||||
|
'title': title,
|
||||||
|
'start_time': asUtc(start).toIso8601String(),
|
||||||
|
'end_time': asUtc(end).toIso8601String(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../auth/providers/auth_provider.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
// Settings / Profile
|
// Profil : nom d'utilisateur + déconnexion. L'avatar était
|
||||||
|
// un bouton mort (onTap vide) — aucune déconnexion possible
|
||||||
|
// depuis le dashboard desktop.
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
child: TvFocusableCard(
|
child: PopupMenuButton<String>(
|
||||||
onTap: () {},
|
tooltip: 'Profil',
|
||||||
borderRadius: 50,
|
color: AppColors.surfaceContainerHigh,
|
||||||
scaleFactor: 1.1,
|
offset: const Offset(56, -12),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
enabled: false,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
ref.read(authProvider).currentUser?.username ??
|
||||||
|
'Utilisateur',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'logout',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.logout,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Déconnexion',
|
||||||
|
style: TextStyle(color: AppColors.onSurface),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'logout') {
|
||||||
|
ref.read(authProvider.notifier).logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: const CircleAvatar(
|
child: const CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: AppColors.surfaceContainerHigh,
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
|||||||
@@ -270,8 +270,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update watch history if relevant
|
// Update watch history if relevant. Jamais en live : la « position »
|
||||||
if (currentTime > 0) {
|
// d'un flux continu n'a pas de sens et polluait le stockage avec une
|
||||||
|
// entrée bidon par chaîne zappée.
|
||||||
|
if (currentTime > 0 && widget.streamType != StreamType.live) {
|
||||||
ref.read(playbackPositionsProvider.notifier).savePosition(
|
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||||
widget.streamId,
|
widget.streamId,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import 'player_screen.dart';
|
import 'player_screen.dart';
|
||||||
@@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Reprise de lecture : le player sauvegarde la position sous
|
||||||
|
// episode.id, on la relit ici pour reprendre où on s'était arrêté.
|
||||||
|
final positions = ref.read(playbackPositionsProvider);
|
||||||
|
final resumeAt = positions.hasPosition(episode.id)
|
||||||
|
? positions.getPosition(episode.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
streamType: StreamType.series,
|
streamType: StreamType.series,
|
||||||
containerExtension: episode.containerExtension ?? 'mkv',
|
containerExtension: episode.containerExtension ?? 'mkv',
|
||||||
duration: episodeDuration,
|
duration: episodeDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
body: channelsAsync.when(
|
body: channelsAsync.when(
|
||||||
loading: () => const ThemedLoading(),
|
loading: () => const ThemedLoading(),
|
||||||
error: (e, s) => Center(
|
error: (e, s) => Center(
|
||||||
child: Text('Error: $e',
|
child: Column(
|
||||||
style: const TextStyle(color: AppColors.onSurface)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Impossible de charger les chaînes',
|
||||||
|
style: TextStyle(color: AppColors.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
onPressed: () => ref.invalidate(
|
||||||
|
liveChannelsByPlaylistProvider(widget.playlist),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
data: (groupedChannels) {
|
data: (groupedChannels) {
|
||||||
var categories = groupedChannels.keys.toList();
|
var categories = groupedChannels.keys.toList();
|
||||||
@@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// Favori (Positioned top left) — toggleFavorite n'était appelé
|
||||||
|
// nulle part : le filtre « Favoris » affichait toujours vide.
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final isFav = ref
|
||||||
|
.watch(favoritesProvider)
|
||||||
|
.contains(channel.streamId);
|
||||||
|
return TvFocusableCard(
|
||||||
|
onTap: () => ref
|
||||||
|
.read(favoritesProvider.notifier)
|
||||||
|
.toggleFavorite(channel.streamId),
|
||||||
|
borderRadius: 20,
|
||||||
|
scaleFactor: 1.2,
|
||||||
|
semanticLabel: isFav
|
||||||
|
? 'Retirer ${channel.name} des favoris'
|
||||||
|
: 'Ajouter ${channel.name} aux favoris',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isFav ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: isFav
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.onSurface54,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
// Record Button Icon Overlay (Positioned top right)
|
// Record Button Icon Overlay (Positioned top right)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
|
|||||||
import '../../../core/widgets/hero_carousel.dart';
|
import '../../../core/widgets/hero_carousel.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Reprise de lecture : les positions étaient sauvegardées par le player
|
||||||
|
// mais jamais relues — le film repartait systématiquement de zéro.
|
||||||
|
final positions = ref.read(playbackPositionsProvider);
|
||||||
|
final resumeAt = positions.hasPosition(movie.streamId)
|
||||||
|
? positions.getPosition(movie.streamId)
|
||||||
|
: null;
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
streamType: StreamType.vod,
|
streamType: StreamType.vod,
|
||||||
containerExtension: movie.containerExtension ?? 'mp4',
|
containerExtension: movie.containerExtension ?? 'mp4',
|
||||||
duration: movieDuration,
|
duration: movieDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../../core/api/authed_http.dart';
|
import '../../../core/api/recording_requests.dart';
|
||||||
import '../../../core/models/iptv_models.dart';
|
import '../../../core/models/iptv_models.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
@@ -43,20 +42,15 @@ class _RecordingModalState extends State<RecordingModal> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
|
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes)
|
// _startTime est une vraie heure locale (pickers) : postRecording la
|
||||||
final response = await AuthedHttp.post(
|
// convertit en UTC — seule convention acceptée par le backend.
|
||||||
Uri.parse('/api/recordings'),
|
final response = await postRecording(
|
||||||
headers: {'Content-Type': 'application/json'},
|
channelId: widget.channel.streamId,
|
||||||
body: json.encode({
|
title: widget.channel.name,
|
||||||
'channel_id': widget.channel.streamId,
|
start: _startTime,
|
||||||
'stream_url': '/api/live/${widget.channel.streamId}.ts',
|
end: endTime,
|
||||||
'title': widget.channel.name,
|
|
||||||
// Forcer UTC pour éviter le décalage +01:00 (France) vs UTC (serveur Docker)
|
|
||||||
'start_time': _startTime.toUtc().toIso8601String(),
|
|
||||||
'end_time': endTime.toUtc().toIso8601String(),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../../core/api/authed_http.dart';
|
import '../../../core/api/authed_http.dart';
|
||||||
|
import '../../../core/api/recording_requests.dart';
|
||||||
import '../../../core/models/iptv_models.dart';
|
import '../../../core/models/iptv_models.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
@@ -14,6 +15,10 @@ import '../screens/player_screen.dart';
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// ENTRÉE — Onglet "Enregistrements"
|
// ENTRÉE — Onglet "Enregistrements"
|
||||||
|
// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste),
|
||||||
|
// Season Passes (enregistrements récurrents). _EpgGuideView et
|
||||||
|
// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis
|
||||||
|
// une refonte : les fonctions étaient codées mais inaccessibles.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class RecordingsTab extends StatefulWidget {
|
class RecordingsTab extends StatefulWidget {
|
||||||
@@ -24,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
|
|||||||
State<RecordingsTab> createState() => _RecordingsTabState();
|
State<RecordingsTab> createState() => _RecordingsTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RecordingsTabState extends State<RecordingsTab> {
|
class _RecordingsTabState extends State<RecordingsTab>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le
|
||||||
|
// plus fréquent ; le guide sert à en programmer de nouveaux.
|
||||||
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -32,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
color: Colors.grey[900],
|
color: Colors.grey[900],
|
||||||
child: const Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
const Row(
|
||||||
SizedBox(width: 12),
|
children: [
|
||||||
Text(
|
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
||||||
'Enregistrements',
|
SizedBox(width: 12),
|
||||||
style: TextStyle(
|
Text(
|
||||||
fontSize: 26,
|
'Enregistrements',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: AppColors.onSurface,
|
fontSize: 26,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
isScrollable: true,
|
||||||
|
indicatorColor: AppColors.primary,
|
||||||
|
labelColor: AppColors.onSurface,
|
||||||
|
unselectedLabelColor: AppColors.onSurface54,
|
||||||
|
tabs: const [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.calendar_month, size: 18),
|
||||||
|
text: 'Guide TV',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.fiber_manual_record, size: 18),
|
||||||
|
text: 'Enregistrements',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.repeat, size: 18),
|
||||||
|
text: 'Season Passes',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _RecordingsListView(playlist: widget.playlist),
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_EpgGuideView(playlist: widget.playlist),
|
||||||
|
_RecordingsListView(playlist: widget.playlist),
|
||||||
|
const _SeasonPassesView(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -594,16 +650,15 @@ class _ProgrammeCard extends StatelessWidget {
|
|||||||
DateTime end,
|
DateTime end,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final response = await AuthedHttp.post(
|
// Les horaires EPG sont des heures UTC naïves : wallClockIsUtc les
|
||||||
Uri.parse('/api/recordings'),
|
// re-tague sans décalage (l'ancien envoi naïf était interprété dans le
|
||||||
headers: {'Content-Type': 'application/json'},
|
// fuseau du serveur → enregistrement décalé de 1-2 h).
|
||||||
body: json.encode({
|
final response = await postRecording(
|
||||||
'channel_id': channel.streamId,
|
channelId: channel.streamId,
|
||||||
'stream_url': '/api/live/${channel.streamId}.ts',
|
title: title,
|
||||||
'title': title,
|
start: start,
|
||||||
'start_time': start.toIso8601String(),
|
end: end,
|
||||||
'end_time': end.toIso8601String(),
|
wallClockIsUtc: true,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if (response.statusCode == 200) notifyRecordingsChanged();
|
if (response.statusCode == 200) notifyRecordingsChanged();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteRecording(String id) async {
|
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
|
||||||
|
/// immédiatement, sans retour ni possibilité d'annuler.
|
||||||
|
Future<void> _deleteRecording(String id, String title) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
backgroundColor: AppColors.surfaceContainer,
|
||||||
|
title: Text(
|
||||||
|
'Supprimer l\'enregistrement ?',
|
||||||
|
style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'« $title » et son fichier seront définitivement supprimés.',
|
||||||
|
style: const TextStyle(color: AppColors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.errorContainer,
|
||||||
|
foregroundColor: AppColors.onErrorContainer,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
|
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
|
||||||
_fetchRecordings();
|
_fetchRecordings();
|
||||||
}
|
}
|
||||||
@@ -914,6 +1001,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
'recording' => AppColors.live,
|
'recording' => AppColors.live,
|
||||||
'completed' => AppColors.success,
|
'completed' => AppColors.success,
|
||||||
'failed' => AppColors.warning,
|
'failed' => AppColors.warning,
|
||||||
|
'cancelled' => AppColors.onSurface38,
|
||||||
_ => AppColors.primaryContainer,
|
_ => AppColors.primaryContainer,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -922,9 +1010,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
'recording' => '● En cours',
|
'recording' => '● En cours',
|
||||||
'completed' => 'Terminé',
|
'completed' => 'Terminé',
|
||||||
'failed' => 'Échoué',
|
'failed' => 'Échoué',
|
||||||
|
'cancelled' => 'Annulé',
|
||||||
_ => status,
|
_ => status,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
String _fmtSize(int bytes) {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go';
|
||||||
|
}
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo';
|
||||||
|
}
|
||||||
|
return '${(bytes / 1024).toStringAsFixed(0)} Ko';
|
||||||
|
}
|
||||||
|
|
||||||
String _fmtDate(dynamic raw) {
|
String _fmtDate(dynamic raw) {
|
||||||
if (raw == null) return '?';
|
if (raw == null) return '?';
|
||||||
try {
|
try {
|
||||||
@@ -974,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null
|
: _error != null
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Column(
|
||||||
_error ?? 'Erreur',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(color: AppColors.live),
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Impossible de charger les enregistrements',
|
||||||
|
style: TextStyle(color: AppColors.live),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
onPressed: _fetchRecordings,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _recordings.isEmpty
|
: _recordings.isEmpty
|
||||||
@@ -1040,12 +1150,34 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
CrossAxisAlignment.start,
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}',
|
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}'
|
||||||
|
'${rec['file_size_bytes'] is int ? ' · ${_fmtSize(rec['file_size_bytes'] as int)}' : ''}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onSurface54,
|
color: AppColors.onSurface54,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (status == 'recording' &&
|
||||||
|
rec['progress_pct'] is int) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(3),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value:
|
||||||
|
(rec['progress_pct'] as int) /
|
||||||
|
100,
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor: AppColors
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.1),
|
||||||
|
valueColor:
|
||||||
|
const AlwaysStoppedAnimation(
|
||||||
|
AppColors.live,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (rec['error_reason'] != null)
|
if (rec['error_reason'] != null)
|
||||||
Text(
|
Text(
|
||||||
'⚠ ${rec['error_reason']}',
|
'⚠ ${rec['error_reason']}',
|
||||||
@@ -1123,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
tooltip: 'Supprimer',
|
tooltip: 'Supprimer',
|
||||||
onPressed: () =>
|
onPressed: () => _deleteRecording(
|
||||||
_deleteRecording(rec['id']),
|
rec['id'],
|
||||||
|
rec['title'] ?? '',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/models/playlist_config.dart';
|
import '../../../../core/models/playlist_config.dart';
|
||||||
import '../../../widgets/mobile_scaffold.dart';
|
import '../../../widgets/mobile_scaffold.dart';
|
||||||
import '../../../theme/mobile_theme.dart';
|
import '../../../theme/mobile_theme.dart';
|
||||||
|
import '../../../../features/iptv/widgets/recordings_tab.dart';
|
||||||
import '../widgets/mobile_live_tv_tab.dart';
|
import '../widgets/mobile_live_tv_tab.dart';
|
||||||
import '../widgets/mobile_movies_tab.dart';
|
import '../widgets/mobile_movies_tab.dart';
|
||||||
import '../widgets/mobile_series_tab.dart';
|
import '../widgets/mobile_series_tab.dart';
|
||||||
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
|
|||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: _buildActiveTab(),
|
// IndexedStack conserve l'état des onglets (position de scroll,
|
||||||
|
// catalogues chargés) : l'ancien switch reconstruisait tout à chaque
|
||||||
|
// changement d'onglet. Même approche que le dashboard desktop.
|
||||||
|
child: IndexedStack(
|
||||||
|
index: _currentIndex,
|
||||||
|
children: [
|
||||||
|
MobileLiveTVTab(playlist: widget.playlist),
|
||||||
|
MobileMoviesTab(playlist: widget.playlist),
|
||||||
|
MobileSeriesTab(playlist: widget.playlist),
|
||||||
|
RecordingsTab(playlist: widget.playlist),
|
||||||
|
const MobileSettingsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActiveTab() {
|
|
||||||
switch (_currentIndex) {
|
|
||||||
case 0:
|
|
||||||
return MobileLiveTVTab(playlist: widget.playlist);
|
|
||||||
case 1:
|
|
||||||
return MobileMoviesTab(playlist: widget.playlist);
|
|
||||||
case 2:
|
|
||||||
return MobileSeriesTab(playlist: widget.playlist);
|
|
||||||
case 3:
|
|
||||||
return const MobileSettingsTab();
|
|
||||||
default:
|
|
||||||
return MobileLiveTVTab(playlist: widget.playlist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MobileChannelTile extends StatelessWidget {
|
class _MobileChannelTile extends ConsumerWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
|
||||||
|
// de l'en-tête affichait toujours une liste vide.
|
||||||
|
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
|
||||||
final iconUrl =
|
final iconUrl =
|
||||||
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
||||||
? '/api/xtream/${channel.streamIcon}'
|
? '/api/xtream/${channel.streamIcon}'
|
||||||
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris',
|
||||||
|
icon: Icon(
|
||||||
|
isFav ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: isFav ? AppColors.primary : AppColors.onSurface54,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(favoritesProvider.notifier)
|
||||||
|
.toggleFavorite(channel.streamId),
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
|
|||||||
activeIcon: Icon(Icons.video_library_rounded),
|
activeIcon: Icon(Icons.video_library_rounded),
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.videocam_outlined),
|
||||||
|
activeIcon: Icon(Icons.videocam_rounded),
|
||||||
|
label: 'REC',
|
||||||
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: Icon(Icons.settings_outlined),
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
|||||||
+1266
File diff suppressed because it is too large.
Load diff
@@ -131,7 +131,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
XFPlayer.prototype.send = function (msg) {
|
XFPlayer.prototype.send = function (msg) {
|
||||||
try { global.parent.postMessage(msg, '*'); } catch (e) {}
|
// Cible restreinte à notre origine : l'iframe est toujours même-origine
|
||||||
|
// que le parent Flutter, un wildcard '*' livrerait l'état du player à
|
||||||
|
// n'importe quelle page qui embarquerait player.html.
|
||||||
|
try { global.parent.postMessage(msg, global.location.origin); } catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------- Démarrage ----------------
|
// ---------------- Démarrage ----------------
|
||||||
@@ -499,6 +502,9 @@
|
|||||||
XFPlayer.prototype._wireParentMessages = function () {
|
XFPlayer.prototype._wireParentMessages = function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
global.addEventListener('message', function (event) {
|
global.addEventListener('message', function (event) {
|
||||||
|
// N'accepter que les commandes émises par notre propre origine
|
||||||
|
// (le côté Flutter filtre déjà les messages entrants de la même façon).
|
||||||
|
if (event.origin !== global.location.origin) return;
|
||||||
var d = event.data;
|
var d = event.data;
|
||||||
if (!d || !d.type) return;
|
if (!d || !d.type) return;
|
||||||
var v = self.video;
|
var v = self.video;
|
||||||
|
|||||||
Reference in new issue
Block a user