Files
Flaro/server/src/Flaro.Api/PackageCoordinator.cs
T
ScarriffleandClaude Opus 5 ac04901ccc Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 15:55:56 +02:00

162 lines
7.0 KiB
C#

using System.Collections.Concurrent;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
public sealed record PackageState(
string Status,
double Progress,
DateTimeOffset StartedAt,
DateTimeOffset? FinishedAt,
string? Error);
/// <summary>Packt Dateien im Hintergrund um und weiss, wie weit das ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht.</b>
/// Der bequeme Weg wäre, ffmpeg in die Antwort schreiben zu lassen. Dann
/// kennt aber niemand die Länge im Voraus: kein Fortschrittsbalken, kein
/// Fortsetzen nach einem Abbruch, kein Springen in der fertigen Datei. Genau
/// daran ist das Herunterladen schon einmal gescheitert — bei drei Gigabyte
/// über Mobilfunk ist „von vorn" keine Antwort.
/// </para>
/// <para>
/// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt
/// es <c>FLARO_TRANSCODE</c>, und dafür meldet dieser Weg einen Fortschritt.
/// </para>
/// </remarks>
public sealed class PackageCoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
TimeProvider clock,
ILogger<PackageCoordinator> log)
{
private readonly ConcurrentDictionary<Guid, PackageState> _zustand = new();
/// <summary>Wie viel freier Platz über die Dateigrösse hinaus bleiben muss.</summary>
///
/// <remarks>
/// Eine volle Platte ist der unangenehmste Ausgang: ffmpeg bricht
/// irgendwann ab, und nebenher kann der Server seine Datenbank nicht mehr
/// schreiben. Lieber vorher ablehnen und sagen, woran es liegt.
/// </remarks>
private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024;
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
/// <summary>Wie viele Umwandlungen gerade laufen — für das Dashboard.</summary>
public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running");
/// <summary>Wohin die umgepackte Datei kommt.</summary>
///
/// <remarks>
/// <b>Der Änderungszeitpunkt steckt im Namen.</b> Wird die Quelle ersetzt,
/// heisst das Ergebnis anders — und niemand bekommt die alte Fassung
/// ausgeliefert, bloss weil sie zufällig noch herumliegt.
/// </remarks>
public string PathFor(Guid fileId, DateTimeOffset geändert) =>
Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4");
public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert));
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
public bool TryStart(Guid fileId)
{
var start = new PackageState("running", 0, clock.GetUtcNow(), null, null);
var vorher = _zustand.AddOrUpdate(fileId, start, (_, alt) => alt.Status == "running" ? alt : start);
if (!ReferenceEquals(vorher, start)) return false;
_ = Task.Run(() => Laufen(fileId));
return true;
}
private async Task Laufen(Guid fileId)
{
try
{
using var bereich = scopes.CreateScope();
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == fileId)
?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek.");
if (!File.Exists(datei.Path))
throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.");
PlatzPruefen(datei.SizeBytes);
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == fileId).OrderBy(s => s.Index).ToListAsync();
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
var plan = DeliveryDecider.ForDownload(datei.Container, gelesen);
var ziel = PathFor(fileId, datei.ModifiedAt);
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
AelteresWegraeumen(fileId, ziel);
var fortschritt = new Progress<double>(wert =>
_zustand.AddOrUpdate(fileId,
_ => new PackageState("running", wert, clock.GetUtcNow(), null, null),
(_, alt) => alt with { Progress = wert }));
var umpacker = bereich.ServiceProvider.GetRequiredService<FfmpegRemuxer>();
var ergebnis = await umpacker.RunAsync(
new RemuxRequest(datei.Path, ziel, gelesen, plan), datei.Duration, fortschritt);
_zustand[fileId] = ergebnis.Success
? new PackageState("ready", 1, _zustand[fileId].StartedAt, clock.GetUtcNow(), null)
: new PackageState("failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), ergebnis.Error);
}
catch (Exception fehler)
{
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
// Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz.
_zustand[fileId] = new PackageState(
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(),
Fehlertext.FuerMenschen(fehler));
}
}
/// <summary>Bricht ab, bevor die Platte volläuft.</summary>
private void PlatzPruefen(long quellgrösse)
{
try
{
var platte = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfade.Transcode)) ?? "/");
var gebraucht = quellgrösse + Sicherheitsabstand;
if (platte.AvailableFreeSpace >= gebraucht) return;
throw new InvalidOperationException(
$"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " +
$"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " +
$"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein.");
}
catch (Exception fehler) when (fehler is ArgumentException or IOException or UnauthorizedAccessException)
{
// Lässt sich der freie Platz nicht ermitteln, wird es versucht.
// Eine Vorsichtsprüfung darf nicht selbst zum Hindernis werden.
log.LogDebug(fehler, "Der freie Platz auf {Pfad} liess sich nicht ermitteln.", pfade.Transcode);
}
}
/// <summary>Löscht frühere Fassungen derselben Datei.</summary>
private void AelteresWegraeumen(Guid fileId, string behalten)
{
var ordner = Path.GetDirectoryName(behalten)!;
foreach (var alt in Directory.EnumerateFiles(ordner, $"{fileId:N}-*.mp4"))
{
if (string.Equals(alt, behalten, StringComparison.Ordinal)) continue;
try { File.Delete(alt); }
catch (IOException fehler) { log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", alt); }
}
}
}