Der Kratzplatz räumt sich auf

39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.

**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.

**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.

Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.

„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. Die Zugriffszeit wäre das Naheliegende, nur
führt sie nicht jedes Dateisystem — mit `noatime` steht sie still, und der
Aufräumer hielte alles für uralt.

Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.

**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.

**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: eine .vtt ist
Kilobytes, und eine gültige wird wieder gebraucht — sie nach Platz zu
löschen hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen. Eine leere
bleibt ausdrücklich liegen: sie ist das Ergebnis „diese Spur hat keinen
Text", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.

**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.

Gemessen am laufenden Server mit Attrappen:

    vorher    packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
    nachher   14 MB freigegeben, 4 Dateien
              gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
              verwaiste .vtt weg, gültige blieben, Stromordner weg

    Obergrenze 1 MB, Datei in der Schonfrist   -> bleibt
    Obergrenze 1 MB, Schonfrist vorbei         -> 3072 KB freigegeben

In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.

714 Tests grün, davon 36 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 19:07:52 +02:00
co-authored by Claude Opus 5
parent 1b4784f1db
commit c4af182d31
19 changed files with 1490 additions and 17 deletions
+19 -1
View File
@@ -26,8 +26,26 @@ public sealed record SystemInfo(
/// lägen die Dateien woanders als die Datenbank sie vermutet.
/// </param>
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
/// <summary>Wie viel auf dem Kratzplatz liegen bleiben darf.</summary>
///
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
/// <param name="GraceMinutes">Wie lange frisch Gebrauchtes in jedem Fall bleibt.</param>
public sealed record MaintenanceSettingsRequest(
double? CapGb, int? UnusedDays, int? GraceMinutes);
public sealed record MaintenanceSettingsResponse(
double CapGb, int UnusedDays, int GraceMinutes);
/// <param name="Teile">
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
/// </param>
public sealed record Speicherort(
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder,
IReadOnlyList<Teilbericht>? Teile = null);
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
@@ -25,7 +25,8 @@ public static class DeliveryEndpoints
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
// Genau das ist passiert: der Spieler bekam HTML statt Video.
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr,
HttpContext zusammenhang) =>
{
var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound();
@@ -47,6 +48,18 @@ public static class DeliveryEndpoints
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
if (File.Exists(fertig))
{
// **Festhalten, solange die Antwort läuft.** Ein Download
// über Mobilfunk dauert Stunden, und zwischen zwei
// Bereichsanfragen kommt lange nichts — ohne diesen Griff
// räumte der Aufräumer die Datei unter dem Client weg, und
// der finge von vorn an.
var griff = packer.Benutzen(fertig);
zusammenhang.Response.OnCompleted(() => { griff.Dispose(); return Task.CompletedTask; });
// Und festhalten, dass sie gebraucht wurde: die Änderungszeit
// ist der Benutzungsstempel, nach dem der Aufräumer sortiert.
packer.AlsBenutztStempeln(fertig);
return Results.File(fertig, "video/mp4",
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
enableRangeProcessing: true);
@@ -151,6 +151,44 @@ public static class SettingsEndpoints
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
}).RequireAuthorization(FlaroPolicies.Administrator);
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
app.MapGet("/api/v1/settings/maintenance", async (Kratzplatzeinstellungen einstellungen) =>
{
var regeln = await einstellungen.RegelnAsync();
return Results.Ok(new MaintenanceSettingsResponse(
regeln.ObergrenzeBytes / 1024d / 1024 / 1024,
(int)regeln.Unbenutzt.TotalDays,
(int)regeln.Schonfrist.TotalMinutes));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/maintenance", async (
MaintenanceSettingsRequest anfrage, FlaroContext db, Kratzplatzeinstellungen einstellungen) =>
{
var vorher = await einstellungen.RegelnAsync();
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
// kennt, soll die anderen nicht zurücksetzen.
var nachher = Kratzplatzregeln.Geprueft(
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
var zahl = System.Globalization.CultureInfo.InvariantCulture;
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
await db.SaveChangesAsync();
return Results.Ok(new MaintenanceSettingsResponse(
nachher.ObergrenzeBytes / 1024d / 1024 / 1024,
(int)nachher.Unbenutzt.TotalDays,
(int)nachher.Schonfrist.TotalMinutes));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
@@ -251,11 +251,15 @@ public static class SystemEndpoints
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
// nachsieht — nicht der letzte.
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Kratzplatzpflege pflege) =>
{
Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
// sind — und davon hängt ab, ob es ein Problem ist.
name == "Umpacken" ? pflege.Belegung() : null);
return Results.Ok(new[]
{
@@ -265,6 +269,17 @@ public static class SystemEndpoints
});
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Kratzplatz aufräumen.**
//
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
// von Gebrauchtem unterscheidet und einen laufenden Download in Ruhe
// lässt, ist keine Löschung eines Dings.
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
Kratzplatzpflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.**
//
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene