Enregistrements : droits du dossier compatibles Plex (PUID/PGID), code 243 expliqué (#34)

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 <noreply@anthropic.com>
This commit is contained in:
LogiFlowandClaude authored and GitHub committed 2026-10-23 12:09:07 +02:00
1 parent 0f5b087eb7
commit 2f8f09e12f
8 files changed
+124 -11

No files matched your search

+1
View File
@@ -51,6 +51,7 @@
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes - **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`
+2
View File
@@ -35,6 +35,8 @@ Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléa
| Variable | Défaut | Rôle | | 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) |
+3
View File
@@ -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
+4
View File
@@ -30,6 +30,10 @@ services:
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz} - 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
View File
@@ -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
+11 -1
View File
@@ -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<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(
+39
View File
@@ -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.
@@ -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] #[test]
fn duree_de_capture() { fn duree_de_capture() {
let n = now(); let n = now();
+40 -3
View File
@@ -8,7 +8,7 @@ use crate::db::AppDatabase;
use crate::models::Recording; use crate::models::Recording;
use crate::services::recording_decision::{ use crate::services::recording_decision::{
OrphanAction, PostExitAction, RecordingAction, capture_duration, decide_orphan_action, 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::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()
@@ -849,8 +870,11 @@ 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 (é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(), lasted.as_secs(),
delay.as_secs(), delay.as_secs(),
); );
@@ -893,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;
} }
@@ -1234,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);
} }