mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bef3ec99 | ||
|
|
0f5b087eb7 | ||
|
|
6d2a8aee59 | ||
|
|
0f81df1c5e | ||
|
|
6368fd6ee2 | ||
|
|
a6322b57b8 | ||
|
|
ccd983d968 | ||
|
|
52504d4e1a | ||
|
|
5169d0843a | ||
|
|
f45c2af677 | ||
|
|
05770e4218 |
No files matched your search
@@ -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
|
- **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
|
### 🔧 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é
|
- **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)
|
- **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`
|
- **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`
|
||||||
|
|||||||
@@ -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 |
|
| Variable | Défaut | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
| `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) |
|
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||||
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
||||||
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
|
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ services:
|
|||||||
# aucun programme actuel. Vider la variable coupe tout appel sortant.
|
# 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
|
- 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
|
- NVIDIA_GPU=true
|
||||||
|
# Unraid : écrire les enregistrements en nobody:users, comme Plex.
|
||||||
|
- PUID=99
|
||||||
|
- PGID=100
|
||||||
volumes:
|
volumes:
|
||||||
- xtremflow-data:/app/data
|
- xtremflow-data:/app/data
|
||||||
- /mnt/user/Data/Sport:/app/recordings
|
- /mnt/user/Data/Sport:/app/recordings
|
||||||
|
|||||||
@@ -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}
|
- 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
|
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
|
||||||
- NVIDIA_GPU=${NVIDIA_GPU:-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
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+24
-7
@@ -4,19 +4,36 @@
|
|||||||
|
|
||||||
set -e
|
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)
|
# Fix ownership of data directory (runs as root initially)
|
||||||
echo "Fixing permissions on /app/data..."
|
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
|
# Create subdirectories if they don't exist
|
||||||
mkdir -p /app/data/logs /app/data/tmp /app/recordings
|
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
|
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
|
# Droits ajoutés, jamais retirés : un ancien `chmod 770` ôtait la lecture
|
||||||
# secours pour les volumes hôtes avec un UID différent — jamais 777)
|
# aux autres utilisateurs et Plex ne voyait plus les enregistrements.
|
||||||
if chown -R xtremuser:xtremuser /app/recordings 2>/dev/null; then
|
chmod -R u+rwX,g+rwX /app/recordings 2>/dev/null || true
|
||||||
chmod -R 770 /app/recordings 2>/dev/null || true
|
|
||||||
else
|
if ! gosu xtremuser test -w /app/recordings; then
|
||||||
chmod -R 775 /app/recordings 2>/dev/null || true
|
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
|
fi
|
||||||
|
|
||||||
# Drop privileges and run the server as xtremuser
|
# Drop privileges and run the server as xtremuser
|
||||||
|
|||||||
@@ -24,12 +24,30 @@ class AuthedHttp {
|
|||||||
return (t != null && t.isNotEmpty) ? t : null;
|
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
|
/// POURQUOI : `localStorage['selected_playlist']` n'est écrit que par un
|
||||||
/// plus récente, et guide, enregistrements et Auto NBA visaient la
|
/// écouteur Riverpod enregistré tardivement ; après un changement d'IPTV
|
||||||
/// mauvaise IPTV quand plusieurs étaient configurées.
|
/// 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 {
|
static String? get selectedPlaylistId {
|
||||||
|
if (_currentPlaylistId != null) return _currentPlaylistId;
|
||||||
try {
|
try {
|
||||||
final stored = html.window.localStorage['selected_playlist'];
|
final stored = html.window.localStorage['selected_playlist'];
|
||||||
if (stored == null) return null;
|
if (stored == null) return null;
|
||||||
|
|||||||
@@ -72,7 +72,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
builder: (context, state) {
|
// 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);
|
final playlist = ref.watch(selectedPlaylistProvider);
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
@@ -80,14 +87,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Adaptive layout without forcing a router rebuild on every pixel change
|
||||||
final isMobile = MediaQuery.sizeOf(context).width < 768;
|
final isMobile = MediaQuery.sizeOf(context).width < 768;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return MobileDashboardScreen(playlist: playlist);
|
return MobileDashboardScreen(key: key, playlist: playlist);
|
||||||
}
|
}
|
||||||
return DashboardScreen(playlist: playlist);
|
return DashboardScreen(key: key, playlist: playlist);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
builder: (context, state) => const AdminPanel(),
|
builder: (context, state) => const AdminPanel(),
|
||||||
|
|||||||
@@ -57,14 +57,69 @@ final selectedPlaylistProvider = StateProvider<PlaylistConfig?>((ref) {
|
|||||||
return null;
|
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
|
// Listener to save selected playlist
|
||||||
final playlistPersistenceProvider = Provider<void>((ref) {
|
final playlistPersistenceProvider = Provider<void>((ref) {
|
||||||
ref.listen(selectedPlaylistProvider, (previous, next) {
|
ref.listen(selectedPlaylistProvider, (previous, next) {
|
||||||
if (next != null) {
|
persistSelectedPlaylist(next);
|
||||||
html.window.localStorage['selected_playlist'] = jsonEncode(next.toJson());
|
|
||||||
} else {
|
|
||||||
html.window.localStorage.remove('selected_playlist');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 '../../auth/providers/auth_provider.dart';
|
||||||
|
import '../../../core/api/authed_http.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';
|
||||||
@@ -10,6 +11,7 @@ import '../widgets/movies_tab.dart';
|
|||||||
import '../widgets/series_tab.dart';
|
import '../widgets/series_tab.dart';
|
||||||
import '../widgets/settings_tab.dart';
|
import '../widgets/settings_tab.dart';
|
||||||
import '../widgets/recordings_tab.dart';
|
import '../widgets/recordings_tab.dart';
|
||||||
|
import '../widgets/playlist_selector_button.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerStatefulWidget {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
@@ -42,6 +44,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@@ -201,6 +205,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
const Spacer(),
|
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
|
// Profil : nom d'utilisateur + déconnexion. L'avatar était
|
||||||
// un bouton mort (onTap vide) — aucune déconnexion possible
|
// un bouton mort (onTap vide) — aucune déconnexion possible
|
||||||
// depuis le dashboard desktop.
|
// depuis le dashboard desktop.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../../core/services/playlist_api_service.dart';
|
import '../../../core/services/playlist_api_service.dart';
|
||||||
|
import '../../../core/api/authed_http.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/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
@@ -163,9 +164,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
child: _PlaylistCard(
|
child: _PlaylistCard(
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
AuthedHttp.usePlaylist(
|
||||||
.read(selectedPlaylistProvider.notifier)
|
playlist.id,
|
||||||
.state = playlist;
|
json: playlist.toJson(),
|
||||||
|
);
|
||||||
|
selectPlaylist(ref, playlist);
|
||||||
context.go('/dashboard');
|
context.go('/dashboard');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
void _saveToLocalCache(String key, Map<String, dynamic> data) {
|
||||||
if (!kIsWeb) return;
|
if (!kIsWeb) return;
|
||||||
try {
|
final storageKey = 'xtream_cache_${_currentPlaylist?.id}_$key';
|
||||||
final jsonStr = jsonEncode(data);
|
final jsonStr = jsonEncode(data);
|
||||||
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
|
try {
|
||||||
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) {
|
} catch (e) {
|
||||||
debugPrint('Error saving to local cache ($key): $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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ class _RecordingsTabState extends State<RecordingsTab>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Guide, enregistrements, season passes et Auto NBA visent cette IPTV.
|
||||||
|
AuthedHttp.usePlaylist(widget.playlist.id);
|
||||||
return Container(
|
return Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -112,7 +114,7 @@ class _RecordingsTabState extends State<RecordingsTab>
|
|||||||
_EpgGuideView(playlist: widget.playlist),
|
_EpgGuideView(playlist: widget.playlist),
|
||||||
_RecordingsListView(playlist: widget.playlist),
|
_RecordingsListView(playlist: widget.playlist),
|
||||||
const _SeasonPassesView(),
|
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 {
|
Future<void> _showLogs(String id, String title) async {
|
||||||
try {
|
try {
|
||||||
final response = await AuthedHttp.get(Uri.parse('/api/recordings/logs/$id'));
|
final response = await AuthedHttp.get(Uri.parse('/api/recordings/logs/$id'));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final content = response.statusCode == 200
|
final content = response.statusCode == 200
|
||||||
? (json.decode(response.body)['logs'] as String? ?? 'Aucun log')
|
? (json.decode(response.body)['logs'] as String? ?? 'Aucun log')
|
||||||
: 'Logs non disponibles (${response.statusCode})';
|
: 'Logs non disponibles (${response.statusCode})${_errorDetail(response.body)}';
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
@@ -1752,7 +1764,9 @@ const _mois = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
class _AutoNbaView extends StatefulWidget {
|
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
|
@override
|
||||||
State<_AutoNbaView> createState() => _AutoNbaViewState();
|
State<_AutoNbaView> createState() => _AutoNbaViewState();
|
||||||
}
|
}
|
||||||
@@ -1764,6 +1778,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
|||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _showOffBein = false;
|
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.
|
// Dernier diagnostic serveur : source, diffusions, chaînes reliées.
|
||||||
Map<String, dynamic>? _diag;
|
Map<String, dynamic>? _diag;
|
||||||
|
|
||||||
@@ -1773,6 +1791,21 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
|||||||
_load();
|
_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 {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = _rule == null;
|
_isLoading = _rule == null;
|
||||||
@@ -1917,14 +1950,19 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
|||||||
|
|
||||||
Future<void> _showSettings() async {
|
Future<void> _showSettings() async {
|
||||||
final rule = _rule;
|
final rule = _rule;
|
||||||
if (rule == null) return;
|
if (rule == null || _openingSettings) return;
|
||||||
|
setState(() => _openingSettings = true);
|
||||||
List<dynamic> channels = [];
|
List<dynamic> channels = [];
|
||||||
|
try {
|
||||||
final r = await AuthedHttp.get(
|
final r = await AuthedHttp.get(
|
||||||
Uri.parse('/api/auto-rules/${rule['id']}/channels'),
|
Uri.parse('/api/auto-rules/${rule['id']}/channels'),
|
||||||
);
|
);
|
||||||
if (r.statusCode == 200) {
|
if (r.statusCode == 200) {
|
||||||
channels = json.decode(r.body)['channels'] as List;
|
channels = json.decode(r.body)['channels'] as List;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _openingSettings = false);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final selected = <String>{
|
final selected = <String>{
|
||||||
@@ -2427,7 +2465,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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(
|
style: GoogleFonts.fraunces(
|
||||||
color: AppColors.onSurface54,
|
color: AppColors.onSurface54,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -2465,12 +2504,21 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
|||||||
onPressed: _busy ? null : _refreshNow,
|
onPressed: _busy ? null : _refreshNow,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: _openingSettings
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
Icons.tune,
|
Icons.tune,
|
||||||
color: AppColors.onSurfaceVariant,
|
color: AppColors.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
tooltip: 'Réglages',
|
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 'dart:html' as html; // For reloading
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../providers/xtream_provider.dart';
|
||||||
import 'streaming_settings_tab.dart';
|
import 'streaming_settings_tab.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/theme_provider.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
@@ -432,6 +433,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
|
|||||||
'Changer de Playlist',
|
'Changer de Playlist',
|
||||||
style: GoogleFonts.karla(color: AppColors.onSurface),
|
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),
|
trailing: const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
|
||||||
onTap: () => context.go('/playlists'),
|
onTap: () => context.go('/playlists'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 '../../../../core/api/authed_http.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';
|
||||||
@@ -26,6 +27,8 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Theme(
|
||||||
data: MobileTheme.darkTheme,
|
data: MobileTheme.darkTheme,
|
||||||
child: MobileScaffold(
|
child: MobileScaffold(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../features/iptv/providers/xtream_provider.dart';
|
import '../../../../features/iptv/providers/xtream_provider.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../../../core/services/playlist_api_service.dart';
|
import '../../../../core/services/playlist_api_service.dart';
|
||||||
|
import '../../../../core/api/authed_http.dart';
|
||||||
import '../../../../core/models/playlist_config.dart';
|
import '../../../../core/models/playlist_config.dart';
|
||||||
import '../../../../features/auth/providers/auth_provider.dart';
|
import '../../../../features/auth/providers/auth_provider.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
@@ -108,9 +109,10 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
return _MobilePlaylistCard(
|
return _MobilePlaylistCard(
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Update provider which will be persisted
|
AuthedHttp.usePlaylist(playlist.id, json: playlist.toJson());
|
||||||
ref.read(selectedPlaylistProvider.notifier).state =
|
// Choix + persistance dans le même geste : l'en-tête
|
||||||
playlist;
|
// 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)
|
// Navigate without extra (it will be picked up from provider)
|
||||||
context.go('/dashboard');
|
context.go('/dashboard');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../../../features/auth/providers/auth_provider.dart';
|
import '../../../../features/auth/providers/auth_provider.dart';
|
||||||
import '../../../../features/iptv/providers/settings_provider.dart';
|
import '../../../../features/iptv/providers/settings_provider.dart';
|
||||||
|
import '../../../../features/iptv/providers/xtream_provider.dart';
|
||||||
import '../../../../core/providers/theme_provider.dart';
|
import '../../../../core/providers/theme_provider.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
@@ -364,6 +365,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_SettingsButton(
|
_SettingsButton(
|
||||||
icon: Icons.playlist_play,
|
icon: Icons.playlist_play,
|
||||||
label: 'Change Playlist',
|
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'),
|
onTap: () => context.go('/playlists'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -461,12 +465,16 @@ class _FilterInput extends StatelessWidget {
|
|||||||
class _SettingsButton extends StatelessWidget {
|
class _SettingsButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
/// Valeur courante de ce réglage, affichée sous le libellé.
|
||||||
|
final String? value;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _SettingsButton({
|
const _SettingsButton({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -484,6 +492,10 @@ class _SettingsButton extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(icon, color: AppColors.onSurface),
|
Icon(icon, color: AppColors.onSurface),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -491,6 +503,17 @@ class _SettingsButton extends StatelessWidget {
|
|||||||
color: AppColors.onSurface,
|
color: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (value != null)
|
||||||
|
Text(
|
||||||
|
value!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.karla(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
|
const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ pub async fn auto_rules(state: &SharedState, req: &Req, rest: &str) -> Resp {
|
|||||||
};
|
};
|
||||||
let parts = segments(rest);
|
let parts = segments(rest);
|
||||||
if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) {
|
if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) {
|
||||||
// Une règle NBA par IPTV : celle de la playlist choisie.
|
// Une règle NBA par IPTV : celle de la playlist courante, résolue
|
||||||
let rules = match state.selected_playlist_id(req) {
|
// 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),
|
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, &pid),
|
||||||
None => state.db.get_auto_rules_for_user(&user.id),
|
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,
|
Ok(b) => b,
|
||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
let playlist_id = state
|
let playlist_id = state.scoped_playlist_id(req);
|
||||||
.get_playlist(req)
|
|
||||||
.map(|p| p.id)
|
|
||||||
.filter(|id| !id.is_empty());
|
|
||||||
let existing = match &playlist_id {
|
let existing = match &playlist_id {
|
||||||
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, pid),
|
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, pid),
|
||||||
None => state.db.get_auto_rules_for_user(&user.id),
|
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")
|
if !body.contains_key("channel_ids")
|
||||||
&& let Some(playlist) = state.get_playlist(req)
|
&& 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))
|
rule.channel_ids = bein_channels::preferred_fr(&bein_channels::detect(&table))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| c.stream_id)
|
.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 {
|
let Some(playlist) = state.get_playlist(req) else {
|
||||||
return json(403, &json!({"error": "Playlist non trouvée"}));
|
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 detected = bein_channels::detect(&table);
|
||||||
let list: Vec<Value> = detected
|
let list: Vec<Value> = detected
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+116
-14
@@ -29,6 +29,10 @@ pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Se
|
|||||||
struct ChannelMap {
|
struct ChannelMap {
|
||||||
epg_ids: HashMap<String, String>,
|
epg_ids: HashMap<String, String>,
|
||||||
names: 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>,
|
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`.
|
/// Tables par `stream_id` tirées de `get_live_streams`.
|
||||||
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
|
#[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 epg_ids = HashMap::new();
|
||||||
let mut names = 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
|
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
|
||||||
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
|
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
|
||||||
let text = String::from_utf8_lossy(body);
|
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);
|
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)
|
if let Some(name) = item.get("name").and_then(value_to_string)
|
||||||
&& !name.is_empty()
|
&& !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.
|
/// 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).
|
/// Toutes les chaînes du panneau : `(stream_id, nom)` (cache 6 h).
|
||||||
pub async fn channel_names(&self, playlist: &PlaylistConfig) -> Vec<(String, String)> {
|
/// `(stream_id, nom, catégorie)` des chaînes live de la playlist.
|
||||||
self.channel_map_for(playlist)
|
/// Catégorie vide si le panneau ne l'a pas fournie.
|
||||||
.await
|
pub async fn channel_table(&self, playlist: &PlaylistConfig) -> Vec<(String, String, String)> {
|
||||||
.names
|
let map = self.channel_map_for(playlist).await;
|
||||||
|
map.names
|
||||||
.iter()
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,13 +508,13 @@ async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -
|
|||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
Ok::<_, reqwest::Error>((status, response.bytes().await?))
|
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 {
|
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
|
||||||
Ok(Ok((200, body))) => {
|
Ok(Ok((200, body))) => {
|
||||||
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
|
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
|
||||||
// des flux en cours.
|
// des flux en cours.
|
||||||
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
|
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}"),
|
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"
|
"[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
|
// 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.
|
// 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)
|
Duration::from_secs(120)
|
||||||
} else {
|
} else {
|
||||||
Duration::from_secs(6 * 3600)
|
Duration::from_secs(6 * 3600)
|
||||||
};
|
};
|
||||||
ChannelMap {
|
ChannelMap {
|
||||||
epg_ids,
|
epg_ids: table.epg_ids,
|
||||||
names,
|
names: table.names,
|
||||||
|
category_ids: table.category_ids,
|
||||||
|
categories,
|
||||||
expires_at: Some(Instant::now() + ttl),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -634,7 +715,28 @@ mod tests {
|
|||||||
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
|
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
|
||||||
let body = body_json(resp).await;
|
let body = body_json(resp).await;
|
||||||
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -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),
|
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
|
||||||
_ => state.db.get_all_recordings(),
|
_ => state.db.get_all_recordings(),
|
||||||
};
|
};
|
||||||
// Une IPTV choisie : seulement ses enregistrements.
|
// Chaque IPTV n'affiche que ses propres enregistrements. Une ligne sans
|
||||||
if let Some(pid) = state.selected_playlist_id(req) {
|
// playlist n'appartient à aucune : la rattacher au démarrage est le
|
||||||
recordings.retain(|r| r.playlist_id.as_deref().is_none_or(|p| p == pid));
|
// 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 now = Utc::now();
|
||||||
let mut enriched = Vec::with_capacity(recordings.len());
|
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());
|
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).
|
// Capture sur l'IPTV choisie par le client (à défaut, la plus récente) —
|
||||||
let playlist_id = state
|
// la même que celle sous laquelle la liste s'affichera.
|
||||||
.get_playlist(req)
|
let playlist_id = state.scoped_playlist_id(req);
|
||||||
.map(|p| p.id)
|
|
||||||
.filter(|id| !id.is_empty());
|
|
||||||
let stream_url = match &playlist_id {
|
let stream_url = match &playlist_id {
|
||||||
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
||||||
None => stream_url,
|
None => stream_url,
|
||||||
@@ -285,12 +285,11 @@ pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
|
|||||||
} else {
|
} else {
|
||||||
state.db.get_season_passes_for_user(&user.id)
|
state.db.get_season_passes_for_user(&user.id)
|
||||||
};
|
};
|
||||||
if let Some(pid) = state.selected_playlist_id(req) {
|
// Comme les enregistrements : un season pass appartient à une
|
||||||
passes.retain(|p| {
|
// seule IPTV, et une ligne sans playlist à aucune.
|
||||||
p.get("playlist_id")
|
if let Some(pid) = state.scoped_playlist_id(req) {
|
||||||
.and_then(Value::as_str)
|
passes
|
||||||
.is_none_or(|v| v == pid)
|
.retain(|p| p.get("playlist_id").and_then(Value::as_str) == Some(pid.as_str()));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
json(
|
json(
|
||||||
200,
|
200,
|
||||||
@@ -375,10 +374,7 @@ fn create_pass(state: &SharedState, req: &Req) -> Resp {
|
|||||||
&json!({"error": "Un Season Pass identique existe déjà"}),
|
&json!({"error": "Un Season Pass identique existe déjà"}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let playlist_id = state
|
let playlist_id = state.scoped_playlist_id(req);
|
||||||
.get_playlist(req)
|
|
||||||
.map(|p| p.id)
|
|
||||||
.filter(|id| !id.is_empty());
|
|
||||||
let stream_url = match &playlist_id {
|
let stream_url = match &playlist_id {
|
||||||
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
||||||
None => stream_url,
|
None => stream_url,
|
||||||
|
|||||||
@@ -903,14 +903,24 @@ impl Streaming {
|
|||||||
redact_url(&target)
|
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
|
// Première connexion hors du générateur : elle seule décide du code
|
||||||
// de réponse.
|
// de réponse.
|
||||||
let client = state.http.clone();
|
let client = state.http.clone();
|
||||||
let Some(first) = open_live_upstream(&client, &target).await else {
|
let Some(first) = open_live_upstream(&client, &target).await else {
|
||||||
|
println!("[Live Proxy] {stream_id} : fournisseur injoignable");
|
||||||
return http::text(502, "Upstream connection failed");
|
return http::text(502, "Upstream connection failed");
|
||||||
};
|
};
|
||||||
let status = first.status().as_u16();
|
let status = first.status().as_u16();
|
||||||
if status != 200 {
|
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");
|
return http::text(status, "Upstream refused");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,8 +964,9 @@ impl Streaming {
|
|||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
match open_live_upstream(&client, &target).await {
|
match open_live_upstream(&client, &target).await {
|
||||||
Some(r) if r.status().as_u16() == 200 => next = Some(r),
|
Some(r) if r.status().as_u16() == 200 => next = Some(r),
|
||||||
_ => println!(
|
other => println!(
|
||||||
"[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée"
|
"[Live Proxy] {id} : reconnexion {attempt}/{LIVE_PROXY_MAX_RETRIES} échouée ({})",
|
||||||
|
other.map_or("injoignable".to_string(), |r| format!("HTTP {}", r.status().as_u16()))
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
//! les chaînes françaises, une seule fois par chaîne logique (beIN SPORTS 1,
|
//! les chaînes françaises, une seule fois par chaîne logique (beIN SPORTS 1,
|
||||||
//! 2, 3, MAX 4…), dans la meilleure qualité disponible.
|
//! 2, 3, MAX 4…), dans la meilleure qualité disponible.
|
||||||
|
|
||||||
use crate::models::PlaylistConfig;
|
|
||||||
use crate::services::nba_teams::normalize;
|
use crate::services::nba_teams::normalize;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
/// Une chaîne beIN trouvée dans la liste du panneau.
|
/// 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()),
|
(None, None) => ("bein-sports".to_string(), "beIN SPORTS".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let prefix = COUNTRY_PREFIX
|
let country_prefix = |text: &str| {
|
||||||
.captures(name)
|
COUNTRY_PREFIX
|
||||||
|
.captures(text)
|
||||||
.and_then(|c| c.get(1))
|
.and_then(|c| c.get(1))
|
||||||
.map(|m| m.as_str().to_ascii_lowercase());
|
.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.
|
// 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 rest_of_name = BEIN.replace(&norm, " ");
|
||||||
let name_words = words(&rest_of_name);
|
let name_words = words(&rest_of_name);
|
||||||
let cat_words = words(category);
|
let cat_words = words(category);
|
||||||
let is_marked = |list: &[&str], ws: &[String]| ws.iter().any(|w| list.contains(&w.as_str()));
|
let is_marked = |list: &[&str], ws: &[String]| ws.iter().any(|w| list.contains(&w.as_str()));
|
||||||
let is_fr = match prefix.as_deref() {
|
let explicit = |p: Option<String>| match p.as_deref() {
|
||||||
Some(p) if FRENCH.contains(&p) => true,
|
Some(p) if FRENCH.contains(&p) => Some(true),
|
||||||
Some(p) if FOREIGN.contains(&p) => false,
|
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) {
|
if is_marked(FOREIGN, &name_words) {
|
||||||
false
|
false
|
||||||
} else if is_marked(FRENCH, &name_words) {
|
} else if is_marked(FRENCH, &name_words) {
|
||||||
@@ -215,67 +222,6 @@ pub fn preferred_fr(detected: &[BeinChannel]) -> Vec<BeinChannel> {
|
|||||||
.collect()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -309,6 +255,36 @@ mod tests {
|
|||||||
assert!(!c("US: beIN Sports", "USA SPORTS").unwrap().is_fr);
|
assert!(!c("US: beIN Sports", "USA SPORTS").unwrap().is_fr);
|
||||||
// Aucune indication de pays : on ne devine pas.
|
// Aucune indication de pays : on ne devine pas.
|
||||||
assert!(!c("beIN SPORTS 1", "SPORTS").unwrap().is_fr);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -464,24 +464,33 @@ pub fn map_bein_broadcasts(
|
|||||||
///
|
///
|
||||||
/// POURQUOI : une liste vide (détection ratée à la création) ou un flux
|
/// POURQUOI : une liste vide (détection ratée à la création) ou un flux
|
||||||
/// renommé par le revendeur faisait ignorer tout le guide beIN.
|
/// 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
|
/// [names] : `(stream_id, nom, catégorie)` de la playlist. Un flux choisi
|
||||||
.iter()
|
/// reconnu comme beIN étrangère (nom ou catégorie) est ignoré : le guide
|
||||||
.map(|(id, name)| (id.clone(), name.clone(), String::new()))
|
/// beIN FR ne décrit pas ses programmes, et le panneau peut le refuser
|
||||||
.collect();
|
/// (vu en prod : « BEIN SPORTS 1 FR ULTRA FHD » en « |AR| BEIN SPORTS 4K »,
|
||||||
let detected = crate::services::bein_channels::detect(&table);
|
/// 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();
|
let mut labels: Vec<(String, String)> = Vec::new();
|
||||||
for id in &rule.channel_ids {
|
for id in &rule.channel_ids {
|
||||||
let label = detected
|
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()
|
.iter()
|
||||||
.find(|c| &c.stream_id == id)
|
.find(|(sid, _, _)| sid == id)
|
||||||
.map(|c| c.label.clone())
|
.map(|(_, n, _)| n.clone()),
|
||||||
.or_else(|| {
|
};
|
||||||
names
|
|
||||||
.iter()
|
|
||||||
.find(|(sid, _)| sid == id)
|
|
||||||
.map(|(_, n)| n.clone())
|
|
||||||
});
|
|
||||||
if let Some(label) = label {
|
if let Some(label) = label {
|
||||||
labels.push((id.clone(), label));
|
labels.push((id.clone(), label));
|
||||||
}
|
}
|
||||||
@@ -628,7 +637,7 @@ impl NbaAutoRecorder {
|
|||||||
playlist: Option<&PlaylistConfig>,
|
playlist: Option<&PlaylistConfig>,
|
||||||
) -> (Vec<EpgProgramme>, Value) {
|
) -> (Vec<EpgProgramme>, Value) {
|
||||||
let names = match playlist {
|
let names = match playlist {
|
||||||
Some(p) => self.epg.channel_names(p).await,
|
Some(p) => self.epg.channel_table(p).await,
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
let labels = channel_labels(rule, &names);
|
let labels = channel_labels(rule, &names);
|
||||||
@@ -1210,7 +1219,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chaines_bein_detectees_dans_la_playlist() {
|
fn chaines_bein_detectees_dans_la_playlist() {
|
||||||
let names: Vec<(String, String)> = [
|
let names: Vec<(String, String, String)> = [
|
||||||
("10", "FR - BEIN SPORTS 1 HD"),
|
("10", "FR - BEIN SPORTS 1 HD"),
|
||||||
("11", "FR - BEIN SPORTS 1 FHD"),
|
("11", "FR - BEIN SPORTS 1 FHD"),
|
||||||
("20", "FR| beIN SPORTS 2"),
|
("20", "FR| beIN SPORTS 2"),
|
||||||
@@ -1218,7 +1227,7 @@ mod tests {
|
|||||||
("40", "FR - TF1"),
|
("40", "FR - TF1"),
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(a, b)| (a.to_string(), b.to_string()))
|
.map(|(a, b)| (a.to_string(), b.to_string(), String::new()))
|
||||||
.collect();
|
.collect();
|
||||||
// Règle sans chaîne (détection ratée à la création) : tout est relié.
|
// Règle sans chaîne (détection ratée à la création) : tout est relié.
|
||||||
let mut r = rule();
|
let mut r = rule();
|
||||||
@@ -1238,6 +1247,30 @@ mod tests {
|
|||||||
assert_eq!(labels.len(), 2);
|
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]
|
#[test]
|
||||||
fn un_seul_enregistrement_par_creneau() {
|
fn un_seul_enregistrement_par_creneau() {
|
||||||
let r = rule();
|
let r = rule();
|
||||||
|
|||||||
@@ -74,22 +74,22 @@ pub enum PostExitAction {
|
|||||||
Fail,
|
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
|
/// 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.
|
/// 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(
|
pub fn decide_post_exit_action(
|
||||||
now: DateTime<Utc>,
|
now: DateTime<Utc>,
|
||||||
end: DateTime<Utc>,
|
end: DateTime<Utc>,
|
||||||
exit_code: i32,
|
exit_code: i32,
|
||||||
consecutive_failures: u32,
|
|
||||||
has_file: bool,
|
has_file: bool,
|
||||||
) -> PostExitAction {
|
) -> PostExitAction {
|
||||||
let remaining = end - now;
|
let remaining = end - now;
|
||||||
if remaining > Duration::seconds(60) && consecutive_failures + 1 < MAX_FFMPEG_ATTEMPTS {
|
if remaining > Duration::seconds(60) {
|
||||||
return PostExitAction::Retry;
|
return PostExitAction::Retry;
|
||||||
}
|
}
|
||||||
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
|
// `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,
|
/// 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
|
/// 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.
|
/// relance après coupure) ne doit pas décaler la fin de l'enregistrement.
|
||||||
@@ -189,31 +211,50 @@ mod tests {
|
|||||||
let n = now();
|
let n = now();
|
||||||
let m40 = n + Duration::minutes(40);
|
let m40 = n + Duration::minutes(40);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_post_exit_action(n, m40, 1, 0, true),
|
decide_post_exit_action(n, m40, 1, true),
|
||||||
PostExitAction::Retry
|
PostExitAction::Retry
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
PostExitAction::Retry
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_post_exit_action(n, m40, 1, MAX_FFMPEG_ATTEMPTS - 1, true),
|
decide_post_exit_action(n, n, 0, true),
|
||||||
PostExitAction::Complete
|
PostExitAction::Complete
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_post_exit_action(n, n, 0, 0, true),
|
decide_post_exit_action(n, n, 1, true),
|
||||||
PostExitAction::Complete
|
PostExitAction::Complete
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_post_exit_action(n, n, 1, 0, true),
|
decide_post_exit_action(n, n, 1, false),
|
||||||
PostExitAction::Complete
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decide_post_exit_action(n, n, 1, 0, false),
|
|
||||||
PostExitAction::Fail
|
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]
|
#[test]
|
||||||
fn duree_de_capture() {
|
fn duree_de_capture() {
|
||||||
let n = now();
|
let n = now();
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use crate::dart_time::{DartDateTime, display_local, now_display};
|
|||||||
use crate::db::AppDatabase;
|
use crate::db::AppDatabase;
|
||||||
use crate::models::Recording;
|
use crate::models::Recording;
|
||||||
use crate::services::recording_decision::{
|
use crate::services::recording_decision::{
|
||||||
MAX_FFMPEG_ATTEMPTS, OrphanAction, PostExitAction, RecordingAction, capture_duration,
|
OrphanAction, PostExitAction, RecordingAction, capture_duration, decide_orphan_action,
|
||||||
decide_orphan_action, decide_post_exit_action, decide_recording_action, ffmpeg_retry_delay,
|
decide_post_exit_action, decide_recording_action, describe_ffmpeg_exit, ffmpeg_retry_delay,
|
||||||
};
|
};
|
||||||
use crate::util::b64;
|
use crate::util::b64;
|
||||||
use crate::util::log_redactor::redact_url;
|
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
|
let file_path = dir
|
||||||
.join(file_name_for(&recording))
|
.join(file_name_for(&recording))
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
@@ -828,7 +849,7 @@ impl RecordingScheduler {
|
|||||||
return;
|
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 (lasted, has_data) = {
|
||||||
let state = active.state.lock().unwrap();
|
let state = active.state.lock().unwrap();
|
||||||
(
|
(
|
||||||
@@ -836,21 +857,11 @@ impl RecordingScheduler {
|
|||||||
active_has_data(&active.file_path, &state.extra_parts),
|
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 {
|
if lasted > Duration::from_secs(120) && has_data {
|
||||||
state.consecutive_failures = 0;
|
active.state.lock().unwrap().consecutive_failures = 0;
|
||||||
}
|
}
|
||||||
state.consecutive_failures
|
|
||||||
};
|
|
||||||
|
|
||||||
match decide_post_exit_action(
|
match decide_post_exit_action(Utc::now(), recording.end_utc(), exit_code, has_data) {
|
||||||
Utc::now(),
|
|
||||||
recording.end_utc(),
|
|
||||||
exit_code,
|
|
||||||
consecutive,
|
|
||||||
has_data,
|
|
||||||
) {
|
|
||||||
PostExitAction::Retry => {
|
PostExitAction::Retry => {
|
||||||
let failures = {
|
let failures = {
|
||||||
let mut state = active.state.lock().unwrap();
|
let mut state = active.state.lock().unwrap();
|
||||||
@@ -859,11 +870,13 @@ impl RecordingScheduler {
|
|||||||
state.consecutive_failures
|
state.consecutive_failures
|
||||||
};
|
};
|
||||||
let delay = ffmpeg_retry_delay(failures);
|
let delay = ffmpeg_retry_delay(failures);
|
||||||
|
let cause = describe_ffmpeg_exit(exit_code)
|
||||||
|
.map(|c| format!(", {c}"))
|
||||||
|
.unwrap_or_default();
|
||||||
let message = format!(
|
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(),
|
lasted.as_secs(),
|
||||||
delay.as_secs(),
|
delay.as_secs(),
|
||||||
MAX_FFMPEG_ATTEMPTS - 1
|
|
||||||
);
|
);
|
||||||
println!("[RecordingScheduler] {}: {message}", recording.title);
|
println!("[RecordingScheduler] {}: {message}", recording.title);
|
||||||
active.log(&format!("\n[{}] {message}", now_display()));
|
active.log(&format!("\n[{}] {message}", now_display()));
|
||||||
@@ -904,7 +917,12 @@ impl RecordingScheduler {
|
|||||||
self.finalize(
|
self.finalize(
|
||||||
&active,
|
&active,
|
||||||
"failed",
|
"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;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1245,6 +1263,14 @@ fn file_has_data(path: Option<&str>) -> bool {
|
|||||||
.is_some_and(|m| m.is_file() && m.len() > 0)
|
.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) {
|
fn delete_quietly(path: &str) {
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -37,18 +37,22 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Playlist explicitement choisie par le client (en-tête ou paramètre),
|
/// IPTV de rattachement des listes (enregistrements, season passes,
|
||||||
/// si l'utilisateur y a droit. Sert à filtrer les listes par IPTV ; sans
|
/// règles automatiques).
|
||||||
/// choix explicite (anciens clients), rien n'est filtré.
|
///
|
||||||
pub fn selected_playlist_id(&self, req: &Req) -> Option<String> {
|
/// Résolue exactement comme à l'écriture, via [`Self::get_playlist`] :
|
||||||
let requested = req
|
/// le choix explicite du client s'il est recevable, sinon la playlist la
|
||||||
.query_param("playlist")
|
/// plus récente. Les deux côtés doivent répondre la même chose, sinon une
|
||||||
.filter(|p| !p.trim().is_empty())
|
/// ligne écrite sous la playlist de repli reste visible sous toutes les
|
||||||
.or_else(|| req.header("x-playlist-id").map(str::to_string))?;
|
/// autres — c'est ce que faisait la lecture quand l'en-tête manquait, en
|
||||||
let requested = requested.trim().to_string();
|
/// 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)
|
self.get_playlist(req)
|
||||||
.map(|p| p.id)
|
.map(|p| p.id)
|
||||||
.filter(|id| *id == requested)
|
.filter(|id| !id.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlist Xtream de la requête.
|
/// Playlist Xtream de la requête.
|
||||||
|
|||||||
Reference in new issue
Block a user