mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
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:
1 parent
0f5b087eb7
commit
2f8f09e12f
8 files changed
+124
-11
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
|
||||
|
||||
### 🔧 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`
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,10 @@ services:
|
||||
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
|
||||
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
|
||||
- NVIDIA_GPU=${NVIDIA_GPU:-false}
|
||||
# UID/GID du serveur, à aligner sur le propriétaire du dossier
|
||||
# d'enregistrement (Unraid : 99/100). Vides = utilisateur interne.
|
||||
- PUID=${PUID:-}
|
||||
- PGID=${PGID:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
+24
-7
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user