Serveur réécrit en Rust

Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

-126
View File
@@ -1,126 +0,0 @@
/// Pure decision logic for the recording scheduler, extracted for testability.
enum RecordingAction {
/// Start the recording now.
start,
/// Capacity reached but the window is still open: retry on a later tick.
wait,
/// The end time passed before the recording could start.
fail,
/// Not yet due — nothing to do.
none,
}
/// Decides what to do with a scheduled recording on a scheduler tick.
RecordingAction decideRecordingAction({
required DateTime now,
required DateTime startTime,
required DateTime endTime,
required int activeCount,
required int maxConcurrent,
}) {
final nowUtc = now.toUtc();
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
if (activeCount >= maxConcurrent) return RecordingAction.wait;
return RecordingAction.start;
}
/// Ce que le planificateur doit faire d'un enregistrement resté au statut
/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond
/// (redémarrage du conteneur, crash de l'isolate…).
enum OrphanAction {
/// La fenêtre est encore ouverte : relancer la capture.
resume,
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
finish,
/// Rien d'exploitable n'a été capturé.
fail,
}
/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick.
OrphanAction decideOrphanAction({
required DateTime now,
required DateTime endTime,
required bool hasFile,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining) return OrphanAction.resume;
return hasFile ? OrphanAction.finish : OrphanAction.fail;
}
/// Ce que le planificateur doit faire quand un processus FFmpeg se termine.
enum PostExitAction {
/// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé.
complete,
/// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre.
retry,
/// Arrêt prématuré sans rien d'exploitable.
fail,
}
/// Nombre d'é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 : une coupure toutes les dix minutes ne doit pas
/// finir par épuiser le quota.
const int maxFfmpegAttempts = 30;
/// Décide de la suite à donner à la sortie d'un processus FFmpeg.
///
/// 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.
PostExitAction decidePostExitAction({
required DateTime now,
required DateTime endTime,
required int exitCode,
required int consecutiveFailures,
required bool hasFile,
int maxAttempts = maxFfmpegAttempts,
Duration minRemaining = const Duration(seconds: 60),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) {
return PostExitAction.retry;
}
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
final cleanExit = exitCode == 0 || exitCode == 255;
return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail;
}
/// Durée de capture à demander à FFmpeg (`-t`).
///
/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur 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.
Duration captureDuration({
required DateTime now,
required DateTime endTime,
Duration minimum = const Duration(seconds: 30),
}) {
final remaining = endTime.toUtc().difference(now.toUtc());
return remaining < minimum ? minimum : remaining;
}
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
///
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
/// amont ne ferait qu'épuiser le quota de relances.
Duration ffmpegRetryDelay(int consecutiveFailures) {
final steps = (consecutiveFailures - 1).clamp(0, 4);
final seconds = 3 * (1 << steps);
return Duration(seconds: seconds > 30 ? 30 : seconds);
}