Compare commits

..
Author SHA1 Message Date
Claude 44bef3ec99 Enregistrements : droits du dossier compatibles Plex (PUID/PGID), code 243 expliqué
Sous Unraid, l'entrypoint faisait chmod -R 770 sur le dossier
d'enregistrement : Plex (nobody:users) ne lisait plus les vidéos. Rendre
la lecture à Plex retirait l'écriture au conteneur, et le match suivant
a échoué sur « Erreur FFmpeg code 243 » (EACCES), sans log puisque le
.log ne pouvait pas non plus être créé.

- entrypoint : PUID/PGID (Unraid 99/100) et UMASK (002) ; les droits du
  dossier sont ajoutés, plus jamais retirés ; avertissement au démarrage
  si le dossier reste non inscriptible.
- Serveur : vérification d'écriture avant de lancer FFmpeg, motif clair.
- Codes de sortie FFmpeg courants traduits (243, 8, 228…).
- Dialogue des logs : affiche le message d'erreur du serveur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RD1okqQzNYjujGirBHXpu
2026-10-23 10:06:51 +00:00
MichaelandClaude Opus 5.5 0f5b087eb7 Auto NBA : un seul dialogue de réglages, chargement visible
La liste des chaînes se chargeait sans retour visuel : chaque clic sur
Réglages ouvrait un dialogue de plus. Enregistrer celui du dessus
découvrait l'ancienne sélection en dessous, et l'enregistrer à son tour
rétablissait l'ancien choix de chaînes.

Le bouton est désactivé pendant le chargement, comme « Rafraîchir
maintenant », et affiche le petit indicateur des onglets Films et
Séries (16 × 16, trait 2, AppColors.onSurface).

Vérifié par un agent distinct, après calibration sur une copie portant
une différence plantée. Aucun SDK Flutter sur cette machine : rien n'est
compilé ni rendu ici, la CI compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:42 +02:00
MichaelandClaude Opus 5.5 6d2a8aee59 Auto NBA : écarter les beIN étrangères malgré un « FR » dans le nom
Vu en prod : « BEIN SPORTS 1 FR ULTRA FHD » (550403) est rangée en
« |AR| BEIN SPORTS 4K », hors abonnement ; le panneau la refuse en
HTTP 407. classify ne lisait la catégorie qu'en dernier recours : le
« FR » du nom la classait française, et à qualité égale elle passait
devant la vraie FR - BEIN SPORTS 1 FHD (554021). Toutes les autres
beIN 1 répondent 200.

- classify : le préfixe pays de la catégorie passe avant les mots du
  nom (ordre : préfixe du nom, préfixe de catégorie, mots du nom, mots
  de catégorie).
- La table des chaînes de l'EPG garde la catégorie de chaque flux
  (get_live_categories, une requête par chargement de table) ;
  channel_table remplace channel_names.
- channel_labels ignore un flux choisi reconnu comme beIN étrangère :
  les règles existantes visant 550403 basculent seules sur la FR, et
  les enregistrements programmés sont recréés à la réconciliation.
- /api/auto-rules (création, liste des chaînes) lit la table en cache
  au lieu de retélécharger 7 Mo à chaque ouverture des réglages ;
  fetch_live_table disparaît.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:41 +02:00
MichaelandClaude Opus 5.5 0f81df1c5e Enregistrements : relancer jusqu'à la fin du match, code amont journalisé
Un match NBA a échoué après 30 refus du fournisseur en 13 min, alors
qu'il restait 3 h 20 de diffusion. Le plafond MAX_FFMPEG_ATTEMPTS
condamnait tout l'enregistrement sur un refus temporaire : on relance
désormais tant que la fenêtre est ouverte, au rythme de
ffmpeg_retry_delay (30 s au plus).

- direct_proxy journalise le code HTTP du panneau : FFmpeg n'affiche
  que « 4XX », le code exact (ici 407) n'était visible nulle part.
- direct_proxy libère les connexions de lecture orphelines du compte
  avant d'ouvrir le flux, comme turbo.ts et HLS. Un flux encore regardé
  n'est jamais coupé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:39 +02:00
MichaelandClaude Opus 5.5 6368fd6ee2 Multi-IPTV : déclarer l'IPTV active de façon synchrone dans AuthedHttp
AuthedHttp lisait l'IPTV dans localStorage, écrit par un écouteur
Riverpod enregistré tardivement : après un changement d'IPTV l'en-tête
X-Playlist-Id pouvait rester périmé. AuthedHttp.usePlaylist pose l'IPTV
en mémoire dès la sélection et à chaque construction du tableau de bord.

Auto NBA reçoit la playlist, recharge sa règle quand elle change et
nomme l'IPTV sur laquelle les matchs sont enregistrés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:35 +02:00
MichaelandClaude Opus 5.5 a6322b57b8 Multi-IPTV : changer d'IPTV depuis le rail reste sur le tableau de bord
La route /dashboard faisait ref.watch(selectedPlaylistProvider) avec le
ref du routerProvider : le GoRouter entier dépendait de la playlist. Un
choix dans le sélecteur du rail recréait le routeur, reparti de /login
puis redirigé vers /playlists.

Un Consumer limite la reconstruction à la route, et une clé par IPTV
réinitialise les onglets, qui ne chargent leur catalogue qu'en initState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:35 +02:00
MichaelandClaude Opus 5 ccd983d968 Multi-IPTV : nommer l'IPTV active et en changer depuis le rail
Rien à l'écran ne disait sur quelle IPTV on travaillait. Guide,
enregistrements et Auto NBA appartiennent pourtant chacun à une playlist,
et deux playlists montrant le même contenu passaient pour un défaut de
filtrage alors que la sélection elle-même était invisible. Changer d'IPTV
demandait en plus de descendre dans les Réglages.

- PlaylistSelectorButton : nom de l'IPTV active sous son icône dans le
  rail du tableau de bord, et menu de bascule au clic. Écrit comme
  QualitySelectorButton, l'autre sélecteur du projet — même
  PopupMenuButton sous Tooltip, même surfaceContainerHigh, même rayon 16,
  mêmes marques radio. La bascule passe par selectPlaylist, donc le choix
  est persisté avant la première requête.
- Son décalage vaut 68 et non les 56 du menu Profil : ce dernier est
  mesuré depuis un avatar de 40 centré dans le rail de 80, ce
  déclencheur-ci fait 64. Les deux menus s'ouvrent ainsi à la même
  abscisse.
- MobileScaffold n'ayant aucun porteur en haut, l'IPTV active s'affiche
  en sous-titre de l'entrée « Changer de Playlist », dans les réglages
  mobile et desktop.

Vérifié par un agent distinct, après calibration sur une copie portant
une différence plantée. Aucune erreur de compilation relevée à la
lecture ; rien n'est rendu, donc tailles réelles, débordement et
ouverture effective du menu restent non évalués. Le SDK Flutter de cette
machine (3.10.3) est antérieur au ^3.13.0 exigé par le projet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 14:39:09 +02:00
MichaelandClaude Opus 5 52504d4e1a Multi-IPTV : le quota du navigateur ne doit plus écraser le choix d'IPTV
Console du navigateur, à deux playlists :

  QuotaExceededError: Failed to execute 'setItem' on 'Storage':
  Setting the value of 'xtream_cache_<id>_live_channels' exceeded the quota.

Les catalogues mis en cache (chaînes, films, séries), plusieurs Mo par
IPTV, saturent le stockage. L'erreur était avalée, mais le stockage
restait plein : toute écriture suivante échouait, dont celle de
localStorage['selected_playlist']. L'entrée gardait l'IPTV précédente,
AuthedHttp envoyait un X-Playlist-Id périmé, le serveur résolvait la même
playlist pour les deux, et Auto NBA n'avait qu'une règle commune —
désactivée d'un côté, désactivée de l'autre.

- persistSelectedPlaylist purge les catalogues et réessaye avant de
  renoncer ; elle ne lève plus jamais, sans quoi le clic de sélection
  n'aurait plus posé l'état du tout.
- _saveToLocalCache purge les catalogues des autres IPTV puis réessaye :
  un catalogue se recharge depuis le serveur, le choix d'IPTV ne se
  devine pas.

Non vérifié localement : le SDK Flutter de cette machine (3.10.3) est
antérieur au ^3.13.0 exigé par le projet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 14:15:19 +02:00
MichaelandClaude Opus 5 5169d0843a Multi-IPTV : persister l'IPTV choisie dès le clic
Déconnecter Auto NBA sur une IPTV la déconnectait sur l'autre : les deux
tombaient en fait sur la même règle. L'en-tête X-Playlist-Id était absent,
donc le serveur repliait sur la playlist la plus récente, pour la lecture
comme pour l'écriture.

AuthedHttp lit localStorage['selected_playlist'], écrit jusqu'ici par le
seul ref.listen de playlistPersistenceProvider. Or ce listener n'existe
qu'une fois activeXtreamServiceProvider lu, c'est-à-dire une fois le
tableau de bord construit : le premier choix d'une session changeait
l'état avant que quiconque n'écoute, et n'était jamais écrit.

- persistSelectedPlaylist écrit l'entrée ; le listener l'appelle.
- selectPlaylist persiste puis pose l'état ; les deux écrans de
  sélection (web et mobile) passent par lui.

Non vérifié localement : le SDK Flutter de cette machine (3.10.3) est
antérieur au ^3.13.0 exigé par le projet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 14:11:19 +02:00
MichaelandClaude Opus 5 f45c2af677 Format : rustfmt coupe le receveur de passes.retain
cargo fmt --check refusait le bloc de closure : replié sur une ligne,
l'appel dépassait 100 colonnes, et rustfmt casse alors devant .retain
au lieu de garder les accolades. Sortie de rustfmt appliquée telle
quelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 13:48:31 +02:00
MichaelandClaude Opus 5 05770e4218 Multi-IPTV : la lecture filtre comme l'écriture rattache
Les enregistrements, season passes et la règle Auto NBA s'affichaient
encore sous les deux IPTV. Lecture et écriture ne résolvaient pas la
playlist de la même façon : la création passait par get_playlist, qui
se replie sur la playlist la plus récente, alors que les listes
passaient par selected_playlist_id, qui exigeait un choix explicite du
client et ne filtrait rien du tout sans lui. En-tête X-Playlist-Id
absent ou non recevable, tout ressortait mélangé — et pour Auto NBA le
repli rendait toutes les règles du compte.

- selected_playlist_id devient scoped_playlist_id et résout via
  get_playlist, exactement comme à l'écriture ; None signifie
  désormais « compte sans playlist », plus « ne filtre pas ».
- Listes des enregistrements et des season passes : égalité stricte sur
  playlist_id au lieu de is_none_or, qui gardait les lignes sans
  playlist sous chaque IPTV. Les rattacher est le travail de
  attach_legacy_rows_to_playlists, rejoué à chaque ouverture de la base,
  donc aucune ligne ne devient invisible.
- Les trois chemins de création (enregistrement, season pass, règle)
  utilisent le même helper au lieu de recopier la résolution.

Non compilé localement : ni cargo ni Docker sur cette machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 12:09:28 +02:00
26 changed files with 778 additions and 241 deletions

No files matched your search

+1
View File
@@ -51,6 +51,7 @@
- **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
- **Droits du dossier d'enregistrement compatibles Plex (Unraid)** : l'entrypoint faisait `chmod -R 770` sur le dossier à chaque démarrage, ce qui ôtait la lecture à Plex ; rendre la lecture à Plex retirait l'écriture au conteneur, et chaque capture échouait avec « Erreur FFmpeg code 243 » (`EACCES`) sans même de log. Nouvelles variables `PUID`/`PGID` (Unraid : `99`/`100`) et `UMASK` (`002`) ; les droits ne sont plus qu'ajoutés, jamais retirés. Le serveur vérifie que le dossier est inscriptible avant de lancer FFmpeg et l'écrit en clair, et les codes de sortie FFmpeg courants sont traduits (243 → permission refusée)
- **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`
+2
View File
@@ -35,6 +35,8 @@ Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléa
| Variable | Défaut | Rôle |
|---|---|---|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
| `PUID` / `PGID` | *(utilisateur interne)* | UID/GID du serveur, à aligner sur le propriétaire du dossier d'enregistrement (Unraid : `99` / `100`, comme Plex) |
| `UMASK` | `002` | Droits des fichiers créés (lisibles par tous, groupe en écriture) |
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
+3
View File
@@ -11,6 +11,9 @@ services:
# aucun programme actuel. Vider la variable coupe tout appel sortant.
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz
- NVIDIA_GPU=true
# Unraid : écrire les enregistrements en nobody:users, comme Plex.
- PUID=99
- PGID=100
volumes:
- xtremflow-data:/app/data
- /mnt/user/Data/Sport:/app/recordings
+4
View File
@@ -30,6 +30,10 @@ services:
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
- NVIDIA_GPU=${NVIDIA_GPU:-false}
# UID/GID du serveur, à aligner sur le propriétaire du dossier
# d'enregistrement (Unraid : 99/100). Vides = utilisateur interne.
- PUID=${PUID:-}
- PGID=${PGID:-}
restart: unless-stopped
volumes:
+24 -7
View File
@@ -4,19 +4,36 @@
set -e
# PUID/PGID : faire tourner le serveur sous l'UID/GID de l'hôte (Unraid :
# 99/100 = nobody:users, comme Plex). Les enregistrements appartiennent alors
# au même utilisateur que les autres applis du partage.
if [ -n "$PGID" ] && [ "$PGID" != "$(id -g xtremuser)" ]; then
echo "Using PGID=$PGID for xtremuser"
groupmod -o -g "$PGID" xtremuser
fi
if [ -n "$PUID" ] && [ "$PUID" != "$(id -u xtremuser)" ]; then
echo "Using PUID=$PUID for xtremuser"
usermod -o -u "$PUID" xtremuser
fi
# Fichiers créés lisibles par tous (Plex, Jellyfin…), groupe en écriture.
umask "${UMASK:-002}"
# Fix ownership of data directory (runs as root initially)
echo "Fixing permissions on /app/data..."
chown -R xtremuser:xtremuser /app/data 2>/dev/null || true
chown -R xtremuser:xtremuser /app/data /tmp/xtremflow_streams 2>/dev/null || true
# Create subdirectories if they don't exist
mkdir -p /app/data/logs /app/data/tmp /app/recordings
chown -R xtremuser:xtremuser /app/data/logs /app/data/tmp /app/recordings 2>/dev/null || true
# Garantir les permissions en écriture (770 si chown a réussi, 775 en
# secours pour les volumes hôtes avec un UID différent — jamais 777)
if chown -R xtremuser:xtremuser /app/recordings 2>/dev/null; then
chmod -R 770 /app/recordings 2>/dev/null || true
else
chmod -R 775 /app/recordings 2>/dev/null || true
# Droits ajoutés, jamais retirés : un ancien `chmod 770` ôtait la lecture
# aux autres utilisateurs et Plex ne voyait plus les enregistrements.
chmod -R u+rwX,g+rwX /app/recordings 2>/dev/null || true
if ! gosu xtremuser test -w /app/recordings; then
echo "AVERTISSEMENT: /app/recordings n'est pas accessible en écriture pour" \
"l'UID $(id -u xtremuser) / GID $(id -g xtremuser) : les enregistrements" \
"échoueront. Définir PUID/PGID sur le propriétaire du dossier hôte."
fi
# Drop privileges and run the server as xtremuser
+22 -4
View File
@@ -24,12 +24,30 @@ class AuthedHttp {
return (t != null && t.isNotEmpty) ? t : null;
}
/// IPTV sélectionnée (persistée par `playlistPersistenceProvider`).
/// IPTV active, posée de façon synchrone par l'écran de sélection et le
/// tableau de bord (voir [usePlaylist]).
///
/// Envoyée à chaque appel : sans elle le serveur prenait la playlist la
/// plus récente, et guide, enregistrements et Auto NBA visaient la
/// mauvaise IPTV quand plusieurs étaient configurées.
/// POURQUOI : `localStorage['selected_playlist']` n'est écrit que par un
/// écouteur Riverpod enregistré tardivement ; après un changement d'IPTV
/// il restait périmé, le serveur retombait sur la playlist par défaut et
/// les deux IPTV partageaient la même règle Auto NBA et les mêmes
/// enregistrements.
static String? _currentPlaylistId;
/// Déclare l'IPTV active et la persiste immédiatement.
static void usePlaylist(String id, {Map<String, dynamic>? json}) {
if (id.isEmpty) return;
_currentPlaylistId = id;
if (json != null) {
try {
html.window.localStorage['selected_playlist'] = jsonEncode(json);
} catch (_) {}
}
}
/// IPTV envoyée au serveur : celle déclarée, sinon la dernière persistée.
static String? get selectedPlaylistId {
if (_currentPlaylistId != null) return _currentPlaylistId;
try {
final stored = html.window.localStorage['selected_playlist'];
if (stored == null) return null;
+27 -14
View File
@@ -72,21 +72,34 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/dashboard',
builder: (context, state) {
final playlist = ref.watch(selectedPlaylistProvider);
if (playlist == null) {
return const Scaffold(
body: ThemedLoading(),
);
}
// Le `ref` du routeur ne doit pas surveiller la playlist : un
// `ref.watch` ici rend `routerProvider` dépendant de
// `selectedPlaylistProvider`, si bien que changer d'IPTV depuis le
// tableau de bord recréait le GoRouter, reparti de `/login` puis
// redirigé vers `/playlists`. Le `Consumer` limite la reconstruction
// à cette seule route.
builder: (context, state) => Consumer(
builder: (context, ref, _) {
final playlist = ref.watch(selectedPlaylistProvider);
if (playlist == null) {
return const Scaffold(
body: ThemedLoading(),
);
}
// Adaptive layout without forcing a router rebuild on every pixel change
final isMobile = MediaQuery.sizeOf(context).width < 768;
if (isMobile) {
return MobileDashboardScreen(playlist: playlist);
}
return DashboardScreen(playlist: playlist);
},
// Une clé par IPTV : les onglets chargent leur catalogue dans
// `initState` et ignorent un changement de `playlist`. Sans elle,
// le tableau de bord garderait le contenu de l'IPTV précédente.
final key = ValueKey(playlist.id);
// Adaptive layout without forcing a router rebuild on every pixel change
final isMobile = MediaQuery.sizeOf(context).width < 768;
if (isMobile) {
return MobileDashboardScreen(key: key, playlist: playlist);
}
return DashboardScreen(key: key, playlist: playlist);
},
),
),
GoRoute(
path: '/admin',
@@ -57,14 +57,69 @@ final selectedPlaylistProvider = StateProvider<PlaylistConfig?>((ref) {
return null;
});
/// Préfixe des caches de catalogue (chaînes, films, séries) par IPTV.
const xtreamCachePrefix = 'xtream_cache_';
/// Vide les caches de catalogue pour rendre de la place.
///
/// Ces caches pèsent plusieurs mégaoctets par IPTV et saturent le quota du
/// navigateur ; ils se reconstruisent au prochain chargement.
void clearXtreamCaches() {
final keys = html.window.localStorage.keys
.where((k) => k.startsWith(xtreamCachePrefix))
.toList();
for (final key in keys) {
html.window.localStorage.remove(key);
}
}
/// Écrit l'IPTV courante là où `AuthedHttp` la relit pour poser l'en-tête
/// `X-Playlist-Id`.
///
/// Quota saturé, `setItem` lève : les caches de catalogue remplissent le
/// stockage et faisaient échouer cette écriture-ci, minuscule mais décisive.
/// L'entrée restait sur l'IPTV précédente, l'en-tête désignait la mauvaise
/// playlist, et les deux IPTV retombaient sur la même. Le catalogue cède donc
/// la place, et un échec résiduel ne doit jamais empêcher le changement
/// d'IPTV.
void persistSelectedPlaylist(PlaylistConfig? playlist) {
if (playlist == null) {
try {
html.window.localStorage.remove('selected_playlist');
} catch (_) {}
return;
}
final value = jsonEncode(playlist.toJson());
try {
html.window.localStorage['selected_playlist'] = value;
} catch (_) {
try {
clearXtreamCaches();
html.window.localStorage['selected_playlist'] = value;
} catch (e) {
debugPrint('Impossible de persister l\'IPTV choisie: $e');
}
}
}
/// Choisit l'IPTV courante et la persiste dans le même temps.
///
/// Écrire par le seul `playlistPersistenceProvider` ne suffit pas : son
/// `ref.listen` n'existe qu'une fois `activeXtreamServiceProvider` lu, c'est
/// à dire une fois le tableau de bord construit. Le tout premier choix d'une
/// session partait donc sans être écrit, `AuthedHttp` n'envoyait aucun
/// en-tête, et le serveur repliait sur la playlist la plus récente — les deux
/// IPTV retombaient alors sur la même, d'où une seule règle Auto NBA pour
/// les deux.
void selectPlaylist(WidgetRef ref, PlaylistConfig playlist) {
persistSelectedPlaylist(playlist);
ref.read(selectedPlaylistProvider.notifier).state = playlist;
}
// Listener to save selected playlist
final playlistPersistenceProvider = Provider<void>((ref) {
ref.listen(selectedPlaylistProvider, (previous, next) {
if (next != null) {
html.window.localStorage['selected_playlist'] = jsonEncode(next.toJson());
} else {
html.window.localStorage.remove('selected_playlist');
}
persistSelectedPlaylist(next);
});
});
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
@@ -10,6 +11,7 @@ import '../widgets/movies_tab.dart';
import '../widgets/series_tab.dart';
import '../widgets/settings_tab.dart';
import '../widgets/recordings_tab.dart';
import '../widgets/playlist_selector_button.dart';
class DashboardScreen extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -42,6 +44,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@override
Widget build(BuildContext context) {
// Toutes les requêtes /api/* de ce tableau de bord visent cette IPTV.
AuthedHttp.usePlaylist(widget.playlist.id, json: widget.playlist.toJson());
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
@@ -201,6 +205,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Spacer(),
// IPTV active, nommée et changeable sur place.
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlaylistSelectorButton(current: widget.playlist),
),
// Profil : nom d'utilisateur + déconnexion. L'avatar était
// un bouton mort (onTap vide) — aucune déconnexion possible
// depuis le dashboard desktop.
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/services/playlist_api_service.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
@@ -163,9 +164,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: _PlaylistCard(
playlist: playlist,
onTap: () {
ref
.read(selectedPlaylistProvider.notifier)
.state = playlist;
AuthedHttp.usePlaylist(
playlist.id,
json: playlist.toJson(),
);
selectPlaylist(ref, playlist);
context.go('/dashboard');
},
),
+22 -3
View File
@@ -333,12 +333,31 @@ class XtreamService {
}
}
/// Met le catalogue en cache, en cédant la place plutôt qu'en saturant.
///
/// Un catalogue pèse plusieurs mégaoctets et il y en a un par IPTV : à deux
/// playlists, le quota du navigateur était atteint. L'erreur était avalée
/// ici, mais le stockage restait plein — et toute autre écriture échouait
/// à son tour, à commencer par celle de l'IPTV sélectionnée, bien plus
/// importante que ce cache. On purge donc les catalogues des autres IPTV
/// avant de réessayer, puis on renonce : ils se rechargent depuis le
/// serveur, le choix d'IPTV, lui, ne se devine pas.
void _saveToLocalCache(String key, Map<String, dynamic> data) {
if (!kIsWeb) return;
final storageKey = 'xtream_cache_${_currentPlaylist?.id}_$key';
final jsonStr = jsonEncode(data);
try {
final jsonStr = jsonEncode(data);
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
jsonStr;
html.window.localStorage[storageKey] = jsonStr;
return;
} catch (_) {}
try {
final stale = html.window.localStorage.keys
.where((k) => k.startsWith('xtream_cache_') && k != storageKey)
.toList();
for (final k in stale) {
html.window.localStorage.remove(k);
}
html.window.localStorage[storageKey] = jsonStr;
} catch (e) {
debugPrint('Error saving to local cache ($key): $e');
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../providers/xtream_provider.dart';
import '../screens/playlist_selection_screen.dart';
/// Nomme l'IPTV active et permet d'en changer sans quitter l'onglet courant.
///
/// Rien à l'écran ne disait sur quelle IPTV on travaillait : guide,
/// enregistrements et Auto NBA appartiennent pourtant à une playlist précise,
/// et deux playlists affichant le même contenu passaient pour un défaut du
/// filtrage alors que la sélection elle-même était invisible.
///
/// Écrit comme `QualitySelectorButton`, l'autre sélecteur du projet : même
/// `PopupMenuButton` sous un `Tooltip`, même surface, même forme, mêmes
/// marques de sélection.
class PlaylistSelectorButton extends ConsumerWidget {
/// IPTV active, telle que l'écran la connaît déjà.
final PlaylistConfig current;
const PlaylistSelectorButton({super.key, required this.current});
@override
Widget build(BuildContext context, WidgetRef ref) {
// La liste peut n'être pas encore chargée : le menu se réduit alors à
// l'IPTV courante, qui reste nommée.
final playlists =
ref.watch(playlistsProvider).valueOrNull ?? <PlaylistConfig>[current];
return Tooltip(
message: 'IPTV : ${current.name}',
child: PopupMenuButton<PlaylistConfig>(
initialValue: current,
color: AppColors.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
// Le menu s'ouvre à la même abscisse que celui du profil, juste en
// dessous. Son `Offset(56, -12)` est mesuré depuis un avatar de 40
// centré dans le rail de 80, soit 20 + 56. Ce déclencheur-ci est large
// de 64, donc centré à 8 : il faut 68 pour retomber au même endroit.
offset: const Offset(68, -12),
onSelected: (playlist) {
if (playlist.id == current.id) return;
selectPlaylist(ref, playlist);
},
itemBuilder: (context) => playlists
.map(
(playlist) => PopupMenuItem<PlaylistConfig>(
value: playlist,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
playlist.id == current.id
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 18,
color: playlist.id == current.id
? AppColors.primary
: AppColors.textTertiary,
),
const SizedBox(width: 12),
Flexible(
child: Text(
playlist.name,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.karla(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
),
)
.toList(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.playlist_play,
color: AppColors.primary,
size: 24,
),
),
const SizedBox(height: 4),
// Le nom tient sous l'icône : c'est lui l'information, le reste
// n'est que la poignée pour en changer.
SizedBox(
width: 64,
child: Text(
current.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.karla(
color: AppColors.textTertiary,
fontSize: 11,
),
),
),
],
),
),
);
}
}
+63 -15
View File
@@ -52,6 +52,8 @@ class _RecordingsTabState extends State<RecordingsTab>
@override
Widget build(BuildContext context) {
// Guide, enregistrements, season passes et Auto NBA visent cette IPTV.
AuthedHttp.usePlaylist(widget.playlist.id);
return Container(
color: Colors.black,
child: Column(
@@ -112,7 +114,7 @@ class _RecordingsTabState extends State<RecordingsTab>
_EpgGuideView(playlist: widget.playlist),
_RecordingsListView(playlist: widget.playlist),
const _SeasonPassesView(),
const _AutoNbaView(),
_AutoNbaView(playlist: widget.playlist),
],
),
),
@@ -1067,13 +1069,23 @@ class _RecordingsListViewState extends ConsumerState<_RecordingsListView> {
];
}
/// Message `error` renvoyé par l'API, précédé d'un séparateur.
String _errorDetail(String body) {
try {
final error = json.decode(body)['error'];
return error is String && error.isNotEmpty ? ' : $error' : '';
} catch (_) {
return '';
}
}
Future<void> _showLogs(String id, String title) async {
try {
final response = await AuthedHttp.get(Uri.parse('/api/recordings/logs/$id'));
if (!mounted) return;
final content = response.statusCode == 200
? (json.decode(response.body)['logs'] as String? ?? 'Aucun log')
: 'Logs non disponibles (${response.statusCode})';
: 'Logs non disponibles (${response.statusCode})${_errorDetail(response.body)}';
showDialog(
context: context,
builder: (ctx) => AlertDialog(
@@ -1752,7 +1764,9 @@ const _mois = [
];
class _AutoNbaView extends StatefulWidget {
const _AutoNbaView();
/// IPTV dont la règle est affichée et qui capture les matchs choisis.
final PlaylistConfig playlist;
const _AutoNbaView({required this.playlist});
@override
State<_AutoNbaView> createState() => _AutoNbaViewState();
}
@@ -1764,6 +1778,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
bool _busy = false;
String? _error;
bool _showOffBein = false;
// Liste des chaînes en cours de chargement avant l'ouverture des réglages :
// sans ce verrou, chaque clic ouvrait un dialogue de plus, et enregistrer
// celui du dessus découvrait l'ancienne sélection en dessous.
bool _openingSettings = false;
// Dernier diagnostic serveur : source, diffusions, chaînes reliées.
Map<String, dynamic>? _diag;
@@ -1773,6 +1791,21 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
_load();
}
@override
void didUpdateWidget(covariant _AutoNbaView oldWidget) {
super.didUpdateWidget(oldWidget);
// Autre IPTV : autre règle, autres matchs choisis.
if (oldWidget.playlist.id != widget.playlist.id) {
AuthedHttp.usePlaylist(widget.playlist.id);
setState(() {
_rule = null;
_games = [];
_diag = null;
});
_load();
}
}
Future<void> _load() async {
setState(() {
_isLoading = _rule == null;
@@ -1917,13 +1950,18 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
Future<void> _showSettings() async {
final rule = _rule;
if (rule == null) return;
if (rule == null || _openingSettings) return;
setState(() => _openingSettings = true);
List<dynamic> channels = [];
final r = await AuthedHttp.get(
Uri.parse('/api/auto-rules/${rule['id']}/channels'),
);
if (r.statusCode == 200) {
channels = json.decode(r.body)['channels'] as List;
try {
final r = await AuthedHttp.get(
Uri.parse('/api/auto-rules/${rule['id']}/channels'),
);
if (r.statusCode == 200) {
channels = json.decode(r.body)['channels'] as List;
}
} finally {
if (mounted) setState(() => _openingSettings = false);
}
if (!mounted) return;
@@ -2427,7 +2465,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
children: [
Expanded(
child: Text(
'Matchs NBA en direct sur beIN Sports FR',
'Matchs NBA en direct sur beIN Sports FR · '
'enregistrés sur « ${widget.playlist.name} »',
style: GoogleFonts.fraunces(
color: AppColors.onSurface54,
fontSize: 13,
@@ -2465,12 +2504,21 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
onPressed: _busy ? null : _refreshNow,
),
IconButton(
icon: const Icon(
Icons.tune,
color: AppColors.onSurfaceVariant,
),
icon: _openingSettings
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onSurface,
),
)
: const Icon(
Icons.tune,
color: AppColors.onSurfaceVariant,
),
tooltip: 'Réglages',
onPressed: _showSettings,
onPressed: _openingSettings ? null : _showSettings,
),
],
],
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'dart:html' as html; // For reloading
import '../../auth/providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/xtream_provider.dart';
import 'streaming_settings_tab.dart';
import '../../../core/providers/theme_provider.dart';
import '../../../core/theme/app_colors.dart';
@@ -432,6 +433,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
'Changer de Playlist',
style: GoogleFonts.karla(color: AppColors.onSurface),
),
// L'IPTV active, pour savoir d'où viennent guide, enregistrements
// et Auto NBA avant d'en changer.
subtitle: Text(
ref.watch(selectedPlaylistProvider)?.name ?? 'Aucune',
overflow: TextOverflow.ellipsis,
style: GoogleFonts.karla(
color: AppColors.textTertiary,
fontSize: 11,
),
),
trailing: const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
onTap: () => context.go('/playlists'),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/api/authed_http.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../widgets/mobile_scaffold.dart';
import '../../../theme/mobile_theme.dart';
@@ -26,6 +27,8 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
@override
Widget build(BuildContext context) {
// Toutes les requêtes /api/* de ce tableau de bord visent cette IPTV.
AuthedHttp.usePlaylist(widget.playlist.id, json: widget.playlist.toJson());
return Theme(
data: MobileTheme.darkTheme,
child: MobileScaffold(
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/api/authed_http.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart';
@@ -108,9 +109,10 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
return _MobilePlaylistCard(
playlist: playlist,
onTap: () {
// Update provider which will be persisted
ref.read(selectedPlaylistProvider.notifier).state =
playlist;
AuthedHttp.usePlaylist(playlist.id, json: playlist.toJson());
// Choix + persistance dans le même geste : l'en-tête
// X-Playlist-Id doit partir dès la première requête.
selectPlaylist(ref, playlist);
// Navigate without extra (it will be picked up from provider)
context.go('/dashboard');
},
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../core/providers/theme_provider.dart';
import '../../../../core/theme/app_colors.dart';
@@ -364,6 +365,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_SettingsButton(
icon: Icons.playlist_play,
label: 'Change Playlist',
// L'IPTV active, pour savoir d'où viennent guide, enregistrements
// et Auto NBA avant d'en changer.
value: ref.watch(selectedPlaylistProvider)?.name,
onTap: () => context.go('/playlists'),
),
const SizedBox(height: 32),
@@ -461,12 +465,16 @@ class _FilterInput extends StatelessWidget {
class _SettingsButton extends StatelessWidget {
final IconData icon;
final String label;
/// Valeur courante de ce réglage, affichée sous le libellé.
final String? value;
final VoidCallback onTap;
const _SettingsButton({
required this.icon,
required this.label,
required this.onTap,
this.value,
});
@override
@@ -484,12 +492,27 @@ class _SettingsButton extends StatelessWidget {
children: [
Icon(icon, color: AppColors.onSurface),
const SizedBox(width: 16),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
if (value != null)
Text(
value!,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.karla(
color: AppColors.textTertiary,
fontSize: 11,
),
),
],
),
const Spacer(),
const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
+7 -8
View File
@@ -30,8 +30,10 @@ pub async fn auto_rules(state: &SharedState, req: &Req, rest: &str) -> Resp {
};
let parts = segments(rest);
if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) {
// Une règle NBA par IPTV : celle de la playlist choisie.
let rules = match state.selected_playlist_id(req) {
// Une règle NBA par IPTV : celle de la playlist courante, résolue
// comme à la création. Le repli sur toutes les règles du compte
// montrait la règle d'une IPTV sous les autres.
let rules = match state.scoped_playlist_id(req) {
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, &pid),
None => state.db.get_auto_rules_for_user(&user.id),
};
@@ -148,10 +150,7 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp {
Ok(b) => b,
Err(r) => return r,
};
let playlist_id = state
.get_playlist(req)
.map(|p| p.id)
.filter(|id| !id.is_empty());
let playlist_id = state.scoped_playlist_id(req);
let existing = match &playlist_id {
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, pid),
None => state.db.get_auto_rules_for_user(&user.id),
@@ -182,7 +181,7 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp {
if !body.contains_key("channel_ids")
&& let Some(playlist) = state.get_playlist(req)
{
let table = bein_channels::fetch_live_table(&state.http, &playlist).await;
let table = state.epg.channel_table(&playlist).await;
rule.channel_ids = bein_channels::preferred_fr(&bein_channels::detect(&table))
.into_iter()
.map(|c| c.stream_id)
@@ -371,7 +370,7 @@ async fn channels(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp {
let Some(playlist) = state.get_playlist(req) else {
return json(403, &json!({"error": "Playlist non trouvée"}));
};
let table = bein_channels::fetch_live_table(&state.http, &playlist).await;
let table = state.epg.channel_table(&playlist).await;
let detected = bein_channels::detect(&table);
let list: Vec<Value> = detected
.iter()
+116 -14
View File
@@ -29,6 +29,10 @@ pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Se
struct ChannelMap {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
/// `stream_id` → `category_id` (le nom de catégorie est dans [categories]).
category_ids: HashMap<String, String>,
/// `category_id` → nom (« |AR| BEIN SPORTS 4K »).
categories: HashMap<String, String>,
expires_at: Option<Instant>,
}
@@ -79,10 +83,19 @@ fn value_to_string(v: &Value) -> Option<String> {
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`.
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
/// Tables par `stream_id` tirées de `get_live_streams`.
#[derive(Default)]
struct ChannelTable {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
category_ids: HashMap<String, String>,
}
/// `stream_id` → `epg_channel_id`, nom et `category_id`.
fn parse_channel_table(body: &[u8]) -> ChannelTable {
let mut epg_ids = HashMap::new();
let mut names = HashMap::new();
let mut category_ids = HashMap::new();
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
let text = String::from_utf8_lossy(body);
@@ -100,6 +113,11 @@ fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String,
{
epg_ids.insert(stream_id.clone(), epg_id);
}
if let Some(cat) = item.get("category_id").and_then(value_to_string)
&& !cat.is_empty()
{
category_ids.insert(stream_id.clone(), cat);
}
if let Some(name) = item.get("name").and_then(value_to_string)
&& !name.is_empty()
{
@@ -107,7 +125,27 @@ fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String,
}
}
}
(epg_ids, names)
ChannelTable {
epg_ids,
names,
category_ids,
}
}
/// `category_id` → nom depuis `get_live_categories`.
fn parse_categories(body: &[u8]) -> HashMap<String, String> {
let text = String::from_utf8_lossy(body);
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&text) else {
return HashMap::new();
};
items
.iter()
.filter_map(|c| {
let id = c.get("category_id").and_then(value_to_string)?;
let name = c.get("category_name").and_then(value_to_string)?;
Some((id, name))
})
.collect()
}
/// Epoch (secondes, texte ou entier) → ISO-8601 UTC.
@@ -265,12 +303,21 @@ impl EpgApi {
}
/// Toutes les chaînes du panneau : `(stream_id, nom)` (cache 6 h).
pub async fn channel_names(&self, playlist: &PlaylistConfig) -> Vec<(String, String)> {
self.channel_map_for(playlist)
.await
.names
/// `(stream_id, nom, catégorie)` des chaînes live de la playlist.
/// Catégorie vide si le panneau ne l'a pas fournie.
pub async fn channel_table(&self, playlist: &PlaylistConfig) -> Vec<(String, String, String)> {
let map = self.channel_map_for(playlist).await;
map.names
.iter()
.map(|(id, name)| (id.clone(), name.clone()))
.map(|(id, name)| {
let category = map
.category_ids
.get(id)
.and_then(|c| map.categories.get(c))
.cloned()
.unwrap_or_default();
(id.clone(), name.clone(), category)
})
.collect()
}
@@ -461,13 +508,13 @@ async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new());
let mut table = ChannelTable::default();
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
Ok(Ok((200, body))) => {
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
// des flux en cours.
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
Ok(parsed) => (epg_ids, names) = parsed,
Ok(parsed) => table = parsed,
Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"),
}
}
@@ -480,20 +527,54 @@ async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -
"[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000"
),
}
// Catégories (quelques Ko) : elles disent le pays d'un flux quand son nom
// ment (« BEIN SPORTS 1 FR ULTRA FHD » rangée en « |AR| BEIN SPORTS 4K »).
let categories = if table.names.is_empty() {
HashMap::new()
} else {
load_categories(client, playlist).await
};
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
let ttl = if names.is_empty() {
let ttl = if table.names.is_empty() {
Duration::from_secs(120)
} else {
Duration::from_secs(6 * 3600)
};
ChannelMap {
epg_ids,
names,
epg_ids: table.epg_ids,
names: table.names,
category_ids: table.category_ids,
categories,
expires_at: Some(Instant::now() + ttl),
}
}
async fn load_categories(
client: &reqwest::Client,
playlist: &PlaylistConfig,
) -> HashMap<String, String> {
let url = format!(
"{}/player_api.php?username={}&password={}&action=get_live_categories",
playlist.dns, playlist.username, playlist.password
);
let fetch = async { client.get(&url).send().await?.bytes().await };
match tokio::time::timeout(Duration::from_secs(30), fetch).await {
Ok(Ok(body)) => parse_categories(&body),
Ok(Err(e)) => {
println!(
"[EpgApi] catégories indisponibles : {}",
redact_url(&format!("{e:?}"))
);
HashMap::new()
}
Err(_) => {
println!("[EpgApi] catégories indisponibles : délai dépassé");
HashMap::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -634,7 +715,28 @@ mod tests {
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
let body = body_json(resp).await;
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]);
// Table des chaînes + catégories, une fois par compte : aucun appel
// par chaîne.
assert_eq!(
*actions.lock().unwrap(),
vec!["get_live_streams", "get_live_categories"]
);
}
#[test]
fn table_des_chaines_avec_categories() {
let table = parse_channel_table(
json!([{"stream_id": 550403, "name": "BEIN SPORTS 1 FR ULTRA FHD", "category_id": "789"}])
.to_string()
.as_bytes(),
);
assert_eq!(table.category_ids["550403"], "789");
let cats = parse_categories(
json!([{"category_id": "789", "category_name": "|AR| BEIN SPORTS 4K", "parent_id": 0}])
.to_string()
.as_bytes(),
);
assert_eq!(cats["789"], "|AR| BEIN SPORTS 4K");
}
#[tokio::test]
+14 -18
View File
@@ -62,9 +62,11 @@ async fn list(state: &SharedState, req: &Req) -> Resp {
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
_ => state.db.get_all_recordings(),
};
// Une IPTV choisie : seulement ses enregistrements.
if let Some(pid) = state.selected_playlist_id(req) {
recordings.retain(|r| r.playlist_id.as_deref().is_none_or(|p| p == pid));
// Chaque IPTV n'affiche que ses propres enregistrements. Une ligne sans
// playlist n'appartient à aucune : la rattacher au démarrage est le
// travail de `attach_legacy_rows_to_playlists`, pas l'afficher partout.
if let Some(pid) = state.scoped_playlist_id(req) {
recordings.retain(|r| r.playlist_id.as_deref() == Some(pid.as_str()));
}
let now = Utc::now();
let mut enriched = Vec::with_capacity(recordings.len());
@@ -188,11 +190,9 @@ fn create(state: &SharedState, req: &Req) -> Resp {
);
}
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
// Capture sur l'IPTV choisie par le client (à défaut, la plus récente).
let playlist_id = state
.get_playlist(req)
.map(|p| p.id)
.filter(|id| !id.is_empty());
// Capture sur l'IPTV choisie par le client (à défaut, la plus récente) —
// la même que celle sous laquelle la liste s'affichera.
let playlist_id = state.scoped_playlist_id(req);
let stream_url = match &playlist_id {
Some(pid) => stream_url_for_playlist(&stream_url, pid),
None => stream_url,
@@ -285,12 +285,11 @@ pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
} else {
state.db.get_season_passes_for_user(&user.id)
};
if let Some(pid) = state.selected_playlist_id(req) {
passes.retain(|p| {
p.get("playlist_id")
.and_then(Value::as_str)
.is_none_or(|v| v == pid)
});
// Comme les enregistrements : un season pass appartient à une
// seule IPTV, et une ligne sans playlist à aucune.
if let Some(pid) = state.scoped_playlist_id(req) {
passes
.retain(|p| p.get("playlist_id").and_then(Value::as_str) == Some(pid.as_str()));
}
json(
200,
@@ -375,10 +374,7 @@ fn create_pass(state: &SharedState, req: &Req) -> Resp {
&json!({"error": "Un Season Pass identique existe déjà"}),
);
}
let playlist_id = state
.get_playlist(req)
.map(|p| p.id)
.filter(|id| !id.is_empty());
let playlist_id = state.scoped_playlist_id(req);
let stream_url = match &playlist_id {
Some(pid) => stream_url_for_playlist(&stream_url, pid),
None => stream_url,
+13 -2
View File
@@ -903,14 +903,24 @@ impl Streaming {
redact_url(&target)
);
// Compte à une connexion : un FFmpeg de lecture orphelin (spectateur
// parti, session pas encore expirée) garde le slot et le panneau
// refuse l'enregistrement. On coupe les orphelins comme les autres
// routes ; un flux encore regardé n'est jamais coupé.
self.claim_upstream(&playlist, &format!("direct_{stream_id}"))
.await;
// Première connexion hors du générateur : elle seule décide du code
// de réponse.
let client = state.http.clone();
let Some(first) = open_live_upstream(&client, &target).await else {
println!("[Live Proxy] {stream_id} : fournisseur injoignable");
return http::text(502, "Upstream connection failed");
};
let status = first.status().as_u16();
if status != 200 {
// FFmpeg ne journalise que « 4XX » : le code exact n'est qu'ici.
println!("[Live Proxy] {stream_id} : refusé par le fournisseur (HTTP {status})");
return http::text(status, "Upstream refused");
}
@@ -954,8 +964,9 @@ impl Streaming {
tokio::time::sleep(Duration::from_secs(1)).await;
match open_live_upstream(&client, &target).await {
Some(r) if r.status().as_u16() == 200 => next = Some(r),
_ => println!(
"[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée"
other => println!(
"[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée ({})",
other.map_or("injoignable".to_string(), |r| format!("HTTP {}", r.status().as_u16()))
),
}
}
+50 -74
View File
@@ -6,12 +6,9 @@
//! les chaînes françaises, une seule fois par chaîne logique (beIN SPORTS 1,
//! 2, 3, MAX 4…), dans la meilleure qualité disponible.
use crate::models::PlaylistConfig;
use crate::services::nba_teams::normalize;
use regex::Regex;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::LazyLock;
/// Une chaîne beIN trouvée dans la liste du panneau.
@@ -143,20 +140,30 @@ pub fn classify(stream_id: &str, name: &str, category: &str) -> Option<BeinChann
(None, None) => ("bein-sports".to_string(), "beIN SPORTS".to_string()),
};
let prefix = COUNTRY_PREFIX
.captures(name)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_ascii_lowercase());
// Le préfixe du nom fait foi ; sinon les mots du nom (hors « bein sports
// N ») puis ceux de la catégorie.
let country_prefix = |text: &str| {
COUNTRY_PREFIX
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_ascii_lowercase())
};
// Ordre de confiance : préfixe pays du nom, puis préfixe pays de la
// catégorie, puis mots du nom (hors « bein sports N »), puis mots de la
// catégorie. Le préfixe de catégorie passe avant les mots du nom : vu en
// prod, « BEIN SPORTS 1 FR ULTRA FHD » rangée en « |AR| BEIN SPORTS 4K »
// est refusée par le panneau (HTTP 407) — ce n'est pas la beIN française.
let rest_of_name = BEIN.replace(&norm, " ");
let name_words = words(&rest_of_name);
let cat_words = words(category);
let is_marked = |list: &[&str], ws: &[String]| ws.iter().any(|w| list.contains(&w.as_str()));
let is_fr = match prefix.as_deref() {
Some(p) if FRENCH.contains(&p) => true,
Some(p) if FOREIGN.contains(&p) => false,
_ => {
let explicit = |p: Option<String>| match p.as_deref() {
Some(p) if FRENCH.contains(&p) => Some(true),
Some(p) if FOREIGN.contains(&p) => Some(false),
_ => None,
};
let is_fr = match explicit(country_prefix(name)).or_else(|| explicit(country_prefix(category)))
{
Some(v) => v,
None => {
if is_marked(FOREIGN, &name_words) {
false
} else if is_marked(FRENCH, &name_words) {
@@ -215,67 +222,6 @@ pub fn preferred_fr(detected: &[BeinChannel]) -> Vec<BeinChannel> {
.collect()
}
/// `(stream_id, nom, catégorie)` de toutes les chaînes live du panneau.
pub async fn fetch_live_table(
client: &reqwest::Client,
playlist: &PlaylistConfig,
) -> Vec<(String, String, String)> {
let api = |action: &str| {
format!(
"{}/player_api.php?username={}&password={}&action={action}",
playlist.dns, playlist.username, playlist.password
)
};
let get = |url: String| async move {
let r = client
.get(&url)
.timeout(std::time::Duration::from_secs(90))
.send()
.await
.ok()?;
let body = r.bytes().await.ok()?;
tokio::task::spawn_blocking(move || serde_json::from_slice::<Value>(&body).ok())
.await
.ok()
.flatten()
};
let as_text = |v: Option<&Value>| match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Null) | None => String::new(),
Some(other) => other.to_string(),
};
let categories: HashMap<String, String> = get(api("get_live_categories"))
.await
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|c| {
(
as_text(c.get("category_id")),
as_text(c.get("category_name")),
)
})
.collect();
get(api("get_live_streams"))
.await
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|s| {
let id = as_text(s.get("stream_id"));
let name = as_text(s.get("name"));
if id.is_empty() || name.is_empty() {
return None;
}
let cat = categories
.get(&as_text(s.get("category_id")))
.cloned()
.unwrap_or_default();
Some((id, name, cat))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -309,6 +255,36 @@ mod tests {
assert!(!c("US: beIN Sports", "USA SPORTS").unwrap().is_fr);
// Aucune indication de pays : on ne devine pas.
assert!(!c("beIN SPORTS 1", "SPORTS").unwrap().is_fr);
// Vu en prod : « FR » dans le nom, mais rangée dans une catégorie
// arabe hors abonnement — le panneau refuse le flux (HTTP 407).
assert!(
!c("BEIN SPORTS 1 FR ULTRA FHD", "|AR| BEIN SPORTS 4K")
.unwrap()
.is_fr
);
}
#[test]
fn une_categorie_etrangere_ne_prend_pas_le_pas_sur_la_vraie_fr() {
// Ordre et IDs de la playlist de prod : la fausse FR (550403) gagnait
// l'égalité de qualité face à la vraie FHD (554021).
let items: Vec<(String, String, String)> = [
(
"550403",
"BEIN SPORTS 1 FR ULTRA FHD",
"|AR| BEIN SPORTS 4K",
),
("554021", "FR - BEIN SPORTS 1 FHD", "FR| SPORTS"),
("543395", "FR - BEIN SPORTS 1", "FR| SPORTS"),
]
.iter()
.map(|(id, n, cat)| (id.to_string(), n.to_string(), cat.to_string()))
.collect();
let preferred: Vec<String> = preferred_fr(&detect(&items))
.into_iter()
.map(|c| c.stream_id)
.collect();
assert_eq!(preferred, ["554021"]);
}
#[test]
+52 -19
View File
@@ -464,24 +464,33 @@ pub fn map_bein_broadcasts(
///
/// POURQUOI : une liste vide (détection ratée à la création) ou un flux
/// renommé par le revendeur faisait ignorer tout le guide beIN.
pub fn channel_labels(rule: &AutoRule, names: &[(String, String)]) -> Vec<(String, String)> {
let table: Vec<(String, String, String)> = names
.iter()
.map(|(id, name)| (id.clone(), name.clone(), String::new()))
.collect();
let detected = crate::services::bein_channels::detect(&table);
///
/// [names] : `(stream_id, nom, catégorie)` de la playlist. Un flux choisi
/// reconnu comme beIN étrangère (nom ou catégorie) est ignoré : le guide
/// beIN FR ne décrit pas ses programmes, et le panneau peut le refuser
/// (vu en prod : « BEIN SPORTS 1 FR ULTRA FHD » en « |AR| BEIN SPORTS 4K »,
/// HTTP 407 sur tout un match).
pub fn channel_labels(
rule: &AutoRule,
names: &[(String, String, String)],
) -> Vec<(String, String)> {
let detected = crate::services::bein_channels::detect(names);
let mut labels: Vec<(String, String)> = Vec::new();
for id in &rule.channel_ids {
let label = detected
.iter()
.find(|c| &c.stream_id == id)
.map(|c| c.label.clone())
.or_else(|| {
names
.iter()
.find(|(sid, _)| sid == id)
.map(|(_, n)| n.clone())
});
let label = match detected.iter().find(|c| &c.stream_id == id) {
Some(c) if !c.is_fr => {
println!(
"[NBA] Flux {id} ignoré : « {} » n'est pas une beIN française ({})",
c.name, c.category
);
None
}
Some(c) => Some(c.label.clone()),
None => names
.iter()
.find(|(sid, _, _)| sid == id)
.map(|(_, n, _)| n.clone()),
};
if let Some(label) = label {
labels.push((id.clone(), label));
}
@@ -628,7 +637,7 @@ impl NbaAutoRecorder {
playlist: Option<&PlaylistConfig>,
) -> (Vec<EpgProgramme>, Value) {
let names = match playlist {
Some(p) => self.epg.channel_names(p).await,
Some(p) => self.epg.channel_table(p).await,
None => Vec::new(),
};
let labels = channel_labels(rule, &names);
@@ -1210,7 +1219,7 @@ mod tests {
#[test]
fn chaines_bein_detectees_dans_la_playlist() {
let names: Vec<(String, String)> = [
let names: Vec<(String, String, String)> = [
("10", "FR - BEIN SPORTS 1 HD"),
("11", "FR - BEIN SPORTS 1 FHD"),
("20", "FR| beIN SPORTS 2"),
@@ -1218,7 +1227,7 @@ mod tests {
("40", "FR - TF1"),
]
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.map(|(a, b)| (a.to_string(), b.to_string(), String::new()))
.collect();
// Règle sans chaîne (détection ratée à la création) : tout est relié.
let mut r = rule();
@@ -1238,6 +1247,30 @@ mod tests {
assert_eq!(labels.len(), 2);
}
#[test]
fn un_flux_choisi_d_une_categorie_etrangere_est_remplace() {
// Vu en prod : la règle visait 550403, « FR » dans le nom mais rangée
// en « |AR| BEIN SPORTS 4K », hors abonnement (HTTP 407 à chaque
// tentative). Le guide beIN FR ne décrit pas cette chaîne.
let names: Vec<(String, String, String)> = [
(
"550403",
"BEIN SPORTS 1 FR ULTRA FHD",
"|AR| BEIN SPORTS 4K",
),
("554021", "FR - BEIN SPORTS 1 FHD", "FR| SPORTS"),
]
.iter()
.map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
.collect();
let mut r = rule();
r.channel_ids = vec!["550403".into()];
assert_eq!(
channel_labels(&r, &names),
vec![("554021".to_string(), "beIN SPORTS 1".to_string())]
);
}
#[test]
fn un_seul_enregistrement_par_creneau() {
let r = rule();
+57 -16
View File
@@ -74,22 +74,22 @@ pub enum PostExitAction {
Fail,
}
/// Échecs FFmpeg consécutifs tolérés avant d'abandonner un enregistrement
/// dont la fenêtre est encore ouverte. Le compteur est remis à zéro dès
/// qu'un lancement a duré assez longtemps pour être considéré sain.
pub const MAX_FFMPEG_ATTEMPTS: u32 = 30;
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
///
/// Aucun plafond d'échecs : tant que la fenêtre est ouverte, on relance
/// (au rythme de [ffmpeg_retry_delay]). Sur un compte à une connexion, le
/// panneau refuse le flux tant qu'un autre lecteur l'occupe ; abandonner
/// au bout de quelques minutes faisait perdre tout le match (constaté en
/// prod : 30 refus en 13 min, 3 h 20 de match perdues).
pub fn decide_post_exit_action(
now: DateTime<Utc>,
end: DateTime<Utc>,
exit_code: i32,
consecutive_failures: u32,
has_file: bool,
) -> PostExitAction {
let remaining = end - now;
if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS {
if remaining > Duration::seconds(60) {
return PostExitAction::Retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
@@ -101,6 +101,28 @@ pub fn decide_post_exit_action(
}
}
/// Cause lisible d'un code de sortie FFmpeg, pour le motif d'échec affiché.
///
/// FFmpeg ≥ 7 sort avec son code d'erreur interne (`AVERROR`, négatif)
/// tronqué à un octet : 243 = −13 = `EACCES`. Seules les causes que
/// l'utilisateur peut corriger sont nommées.
pub fn describe_ffmpeg_exit(exit_code: i32) -> Option<&'static str> {
match exit_code {
// −13 EACCES
243 => Some("permission refusée sur le dossier d'enregistrement"),
// −2 ENOENT
254 => Some("fichier ou dossier introuvable"),
// −28 ENOSPC
228 => Some("disque plein"),
// −30 EROFS
226 => Some("dossier d'enregistrement en lecture seule"),
// Erreurs HTTP du fournisseur (AVERROR_HTTP_*)
8 => Some("flux refusé par le fournisseur (erreur HTTP)"),
c if c < 0 => Some("FFmpeg interrompu par un signal"),
_ => None,
}
}
/// Durée de capture (`-t`) : le temps restant jusqu'à la fin programmée,
/// et non la durée théorique du programme — un démarrage tardif (ou une
/// relance après coupure) ne doit pas décaler la fin de l'enregistrement.
@@ -189,31 +211,50 @@ mod tests {
let n = now();
let m40 = n + Duration::minutes(40);
assert_eq!(
decide_post_exit_action(n, m40, 1, 0, true),
decide_post_exit_action(n, m40, 1, true),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 0, 2, true),
decide_post_exit_action(n, m40, 0, true),
PostExitAction::Retry
);
// Compte IPTV à une connexion occupé pendant 13 min : les refus en
// série ne doivent pas faire abandonner les heures restantes.
assert_eq!(
decide_post_exit_action(n, m40, 8, false),
PostExitAction::Retry
);
assert_eq!(
decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true),
decide_post_exit_action(n, n, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 0, 0, true),
decide_post_exit_action(n, n, 1, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, true),
PostExitAction::Complete
);
assert_eq!(
decide_post_exit_action(n, n, 1, 0, false),
decide_post_exit_action(n, n, 1, false),
PostExitAction::Fail
);
}
#[test]
fn causes_de_sortie_ffmpeg() {
assert_eq!(
describe_ffmpeg_exit(243),
Some("permission refusée sur le dossier d'enregistrement")
);
assert_eq!(
describe_ffmpeg_exit(8),
Some("flux refusé par le fournisseur (erreur HTTP)")
);
assert_eq!(
describe_ffmpeg_exit(-9),
Some("FFmpeg interrompu par un signal")
);
assert_eq!(describe_ffmpeg_exit(1), None);
}
#[test]
fn duree_de_capture() {
let n = now();
+46 -20
View File
@@ -7,8 +7,8 @@ use crate::dart_time::{DartDateTime, display_local, now_display};
use crate::db::AppDatabase;
use crate::models::Recording;
use crate::services::recording_decision::{
MAX_FFMPEG_ATTEMPTS, OrphanAction, PostExitAction, RecordingAction, capture_duration,
decide_orphan_action, decide_post_exit_action, decide_recording_action, ffmpeg_retry_delay,
OrphanAction, PostExitAction, RecordingAction, capture_duration, decide_orphan_action,
decide_post_exit_action, decide_recording_action, describe_ffmpeg_exit, ffmpeg_retry_delay,
};
use crate::util::b64;
use crate::util::log_redactor::redact_url;
@@ -631,6 +631,27 @@ impl RecordingScheduler {
}
}
// Un dossier non inscriptible (droits modifiés côté hôte) ferait
// échouer chaque relance de FFmpeg pendant toute la fenêtre, sans
// même de log : on le signale tout de suite.
if let Err(e) = check_writable(&dir) {
println!(
"[RecordingScheduler] Dossier {} non accessible en écriture ({e}) : \"{}\" refusé",
dir.display(),
recording.title
);
self.db.update_recording_status(
&recording.id,
"failed",
None,
Some(&format!(
"Dossier d'enregistrement non accessible en écriture ({e}). Vérifier les droits du dossier hôte (PUID/PGID)."
)),
false,
);
return Ok(());
}
let file_path = dir
.join(file_name_for(&recording))
.to_string_lossy()
@@ -828,7 +849,7 @@ impl RecordingScheduler {
return;
}
// Une capture qui a tenu longtemps repart d'un quota de relances neuf.
// Une capture qui a tenu longtemps repart du délai de relance minimal.
let (lasted, has_data) = {
let state = active.state.lock().unwrap();
(
@@ -836,21 +857,11 @@ impl RecordingScheduler {
active_has_data(&active.file_path, &state.extra_parts),
)
};
let consecutive = {
let mut state = active.state.lock().unwrap();
if lasted > Duration::from_secs(120) && has_data {
state.consecutive_failures = 0;
}
state.consecutive_failures
};
if lasted > Duration::from_secs(120) && has_data {
active.state.lock().unwrap().consecutive_failures = 0;
}
match decide_post_exit_action(
Utc::now(),
recording.end_utc(),
exit_code,
consecutive,
has_data,
) {
match decide_post_exit_action(Utc::now(), recording.end_utc(), exit_code, has_data) {
PostExitAction::Retry => {
let failures = {
let mut state = active.state.lock().unwrap();
@@ -859,11 +870,13 @@ impl RecordingScheduler {
state.consecutive_failures
};
let delay = ffmpeg_retry_delay(failures);
let cause = describe_ffmpeg_exit(exit_code)
.map(|c| format!(", {c}"))
.unwrap_or_default();
let message = format!(
"FFmpeg s'est arrêté (code {exit_code}) après {}s, avant la fin prévue — relance dans {}s (tentative {failures}/{})",
"FFmpeg s'est arrêté (code {exit_code}{cause}) après {}s, avant la fin prévue — relance dans {}s (échec consécutif n°{failures})",
lasted.as_secs(),
delay.as_secs(),
MAX_FFMPEG_ATTEMPTS - 1
);
println!("[RecordingScheduler] {}: {message}", recording.title);
active.log(&format!("\n[{}] {message}", now_display()));
@@ -904,7 +917,12 @@ impl RecordingScheduler {
self.finalize(
&active,
"failed",
Some(&format!("Erreur FFmpeg code {exit_code}. Voir logs.")),
Some(&match describe_ffmpeg_exit(exit_code) {
Some(cause) => {
format!("Erreur FFmpeg code {exit_code} : {cause}. Voir logs.")
}
None => format!("Erreur FFmpeg code {exit_code}. Voir logs."),
}),
)
.await;
}
@@ -1245,6 +1263,14 @@ fn file_has_data(path: Option<&str>) -> bool {
.is_some_and(|m| m.is_file() && m.len() > 0)
}
/// Vérifie qu'un fichier peut être créé dans [dir] (fichier sonde supprimé).
fn check_writable(dir: &Path) -> std::io::Result<()> {
let probe = dir.join(format!(".xtremflow-write-test-{}", std::process::id()));
std::fs::File::create(&probe)?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
fn delete_quietly(path: &str) {
let _ = std::fs::remove_file(path);
}
+14 -10
View File
@@ -37,18 +37,22 @@ pub struct AppState {
}
impl AppState {
/// Playlist explicitement choisie par le client (en-tête ou paramètre),
/// si l'utilisateur y a droit. Sert à filtrer les listes par IPTV ; sans
/// choix explicite (anciens clients), rien n'est filtré.
pub fn selected_playlist_id(&self, req: &Req) -> Option<String> {
let requested = req
.query_param("playlist")
.filter(|p| !p.trim().is_empty())
.or_else(|| req.header("x-playlist-id").map(str::to_string))?;
let requested = requested.trim().to_string();
/// IPTV de rattachement des listes (enregistrements, season passes,
/// règles automatiques).
///
/// Résolue exactement comme à l'écriture, via [`Self::get_playlist`] :
/// le choix explicite du client s'il est recevable, sinon la playlist la
/// plus récente. Les deux côtés doivent répondre la même chose, sinon une
/// ligne écrite sous la playlist de repli reste visible sous toutes les
/// autres — c'est ce que faisait la lecture quand l'en-tête manquait, en
/// ne filtrant alors rien du tout.
///
/// `None` signifie « ce compte n'a aucune playlist », pas « ne filtre
/// pas ».
pub fn scoped_playlist_id(&self, req: &Req) -> Option<String> {
self.get_playlist(req)
.map(|p| p.id)
.filter(|id| *id == requested)
.filter(|id| !id.is_empty())
}
/// Playlist Xtream de la requête.