From 2f8f09e12fb4ea2ed509369b3ad4000bfb1774c5 Mon Sep 17 00:00:00 2001 From: LogiFlow <101972532+R0m1k3@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:09:07 +0200 Subject: [PATCH] =?UTF-8?q?Enregistrements=20:=20droits=20du=20dossier=20c?= =?UTF-8?q?ompatibles=20Plex=20(PUID/PGID),=20code=20243=20expliqu=C3=A9?= =?UTF-8?q?=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Claude-Session: https://claude.ai/code/session_018RD1okqQzNYjujGirBHXpu Co-authored-by: Claude --- CHANGELOG.md | 1 + README.md | 2 + docker-compose.prod.yml | 3 ++ docker-compose.yml | 4 ++ entrypoint.sh | 31 ++++++++++--- lib/features/iptv/widgets/recordings_tab.dart | 12 +++++- server/src/services/recording_decision.rs | 39 +++++++++++++++++ server/src/services/recording_scheduler.rs | 43 +++++++++++++++++-- 8 files changed, 124 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf537c..2bb71d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index c407b70..e7ddd78 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6b44469..15a5348 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ee27b95..06ea11d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index 04dcad3..1bae641 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 57161a5..edb8207 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -1069,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 _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( diff --git a/server/src/services/recording_decision.rs b/server/src/services/recording_decision.rs index 601e079..e4cfda1 100644 --- a/server/src/services/recording_decision.rs +++ b/server/src/services/recording_decision.rs @@ -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. @@ -216,6 +238,23 @@ mod tests { ); } + #[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(); diff --git a/server/src/services/recording_scheduler.rs b/server/src/services/recording_scheduler.rs index 63b37df..2e5af4a 100644 --- a/server/src/services/recording_scheduler.rs +++ b/server/src/services/recording_scheduler.rs @@ -8,7 +8,7 @@ use crate::db::AppDatabase; use crate::models::Recording; use crate::services::recording_decision::{ OrphanAction, PostExitAction, RecordingAction, capture_duration, 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::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() @@ -849,8 +870,11 @@ 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 (échec consécutif n°{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(), ); @@ -893,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; } @@ -1234,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); }