diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index e3a6bab..0381e53 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -26,8 +26,26 @@ public sealed record SystemInfo( /// lägen die Dateien woanders als die Datenbank sie vermutet. /// /// Wie viel davon auf den Bildspeicher entfällt; sonst null. +/// Wie viel auf dem Kratzplatz liegen bleiben darf. +/// +/// Obergrenze in Gigabyte; 0 heisst unbegrenzt. +/// Ab wann Unbenutztes auch unterhalb der Grenze geht; 0 heisst nie. +/// Wie lange frisch Gebrauchtes in jedem Fall bleibt. +public sealed record MaintenanceSettingsRequest( + double? CapGb, int? UnusedDays, int? GraceMinutes); + +public sealed record MaintenanceSettingsResponse( + double CapGb, int UnusedDays, int GraceMinutes); + +/// +/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz. +/// Mit Dateizahl, denn bei den Untertiteln ist die Anzahl das +/// Problem und nicht die Grösse, und das sieht man an Bytes nicht. +/// Additiv: Bilder bleibt stehen, damit kein Client bricht. +/// 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? Teile = null); /// Dateien, die zu keinem Titel gehören — mit Begründung. public sealed record Unzugeordnet(int Gesamt, IReadOnlyList Dateien); diff --git a/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs b/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs index 0901177..5f3906f 100644 --- a/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs @@ -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); diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 2951ad4..ea4f6c9 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -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); } /// Rechnet „steht in Weiterschauen" für alle Zeilen neu. diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 9164e07..9d064e2 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -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 diff --git a/server/src/Flaro.Api/Kratzplatzeinstellungen.cs b/server/src/Flaro.Api/Kratzplatzeinstellungen.cs new file mode 100644 index 0000000..b318cb9 --- /dev/null +++ b/server/src/Flaro.Api/Kratzplatzeinstellungen.cs @@ -0,0 +1,48 @@ +using Flaro.Core.Delivery; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Die Regeln fürs Aufräumen des Kratzplatzes, aus den Einstellungen. +/// +/// +/// Nach dem Vorbild von : einmal je +/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine +/// Migration. +/// +public sealed class Kratzplatzeinstellungen(FlaroContext db) +{ + public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb"; + public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage"; + public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten"; + + private Kratzplatzregeln? _gemerkt; + + public async Task RegelnAsync(CancellationToken ct = default) + { + if (_gemerkt is { } fertig) return fertig; + + var werte = await db.Settings.AsNoTracking() + .Where(e => e.Key == ObergrenzeSchluessel + || e.Key == UnbenutztSchluessel + || e.Key == SchonfristSchluessel) + .ToDictionaryAsync(e => e.Key, e => e.Value, ct); + + var vorgabe = Kratzplatzregeln.Vorgabe; + + _gemerkt = Kratzplatzregeln.Geprueft( + Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024), + (int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays), + (int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes)); + + return _gemerkt; + } + + private static double Zahl(IReadOnlyDictionary werte, string schluessel, double vorgabe) => + werte.TryGetValue(schluessel, out var roh) + && double.TryParse(roh, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var wert) + ? wert + : vorgabe; +} diff --git a/server/src/Flaro.Api/Kratzplatzpflege.cs b/server/src/Flaro.Api/Kratzplatzpflege.cs new file mode 100644 index 0000000..0ff931b --- /dev/null +++ b/server/src/Flaro.Api/Kratzplatzpflege.cs @@ -0,0 +1,262 @@ +using Flaro.Core.Delivery; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Was ein Aufräumlauf freigemacht hat. +public sealed record Aufraeumbericht( + long Frei, int Dateien, IReadOnlyList Teile); + +/// Der Unterordner — stroeme, packages, untertitel. +public sealed record Teilbericht(string Name, long Frei, int Dateien); + +/// Räumt den Kratzplatz auf. +/// +/// +/// +/// Der Anlass. 39 Gigabyte in FLARO_TRANSCODE. Für jede +/// Mitnahme baut der Server eine MP4 in Quellgrösse und löschte sie nie; die +/// einzige Löschung räumte ältere Fassungen derselben Datei weg. Stromordner +/// verschwanden nur beim geordneten Herunterfahren, Untertitel gar nicht. +/// +/// +/// Kein Dienst, kein Takt. Dieser Server hat nirgends einen +/// Hintergrunddienst, und das ist Absicht — alles Wiederkehrende hängt +/// beiläufig an einem Aufruf. Der Aufräumer hält sich daran: er läuft beim +/// Start, vor jedem Umpacken, nach einem Scan und auf Knopfdruck. +/// +/// +/// Einer zur Zeit. Wer nicht hineinkommt, geht weiter; zwei Läufe +/// nebeneinander löschten einander die Dateien unter den Händen weg. +/// +/// +public sealed class Kratzplatzpflege( + IServiceScopeFactory scopes, + FlaroPaths pfade, + PackageCoordinator pakete, + Streamkoordinator stroeme, + TimeProvider uhr, + ILogger log) +{ + private readonly SemaphoreSlim _einerZurZeit = new(1, 1); + + /// Woher der Lauf kam — fürs Protokoll. + public enum Anlass { Start, VorDemUmpacken, NachDemScan, VonHand } + + /// Räumt auf und sagt, was freigeworden ist. + public async Task LaufenAsync(Anlass anlass, CancellationToken ct = default) + { + if (!await _einerZurZeit.WaitAsync(0, ct)) + { + return new Aufraeumbericht(0, 0, []); + } + + try + { + var jetzt = uhr.GetUtcNow(); + + // Beim Start ist jeder Stromordner verwaist — es gibt noch keine + // einzige Sitzung. Im Betrieb nur die, zu denen keine passt. + var stromfrei = anlass == Anlass.Start + ? stroeme.WurzelLeeren() + : stroeme.VerwaisteOrdnerWegraeumen(jetzt); + + var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct); + var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct); + + var teile = new List + { + new("stroeme", stromfrei, 0), + new("packages", paketfrei, paketzahl), + new("untertitel", textfrei, textzahl), + }; + + var bericht = new Aufraeumbericht( + teile.Sum(t => t.Frei), teile.Sum(t => t.Dateien), teile); + + if (bericht.Frei > 0) + { + log.LogInformation( + "Kratzplatz aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).", + anlass, bericht.Frei / (1024 * 1024), bericht.Dateien); + } + + return bericht; + } + finally + { + _einerZurZeit.Release(); + } + } + + /// Verwaiste Pakete, zu alte Pakete und alles über der Obergrenze. + private async Task<(long Frei, int Dateien)> PaketeAufraeumenAsync( + DateTimeOffset jetzt, CancellationToken ct) + { + var ordner = pakete.Ordner; + if (!Directory.Exists(ordner)) return (0, 0); + + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var regeln = await raum.ServiceProvider + .GetRequiredService().RegelnAsync(ct); + + // **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen, + // und wer gerade baut, hat die Datei noch offen — eine liegengebliebene + // ist das Überbleibsel eines Abbruchs. + long frei = 0; + var zahl = 0; + foreach (var teil in Directory.EnumerateFiles(ordner, "*" + Paketname.Teilendung)) + { + if (pakete.IstInBenutzung(teil)) continue; + frei += Loeschen(teil); + zahl++; + } + + var akten = new List(); + foreach (var pfad in Directory.EnumerateFiles(ordner, "*" + Paketname.Endung)) + { + try + { + var info = new FileInfo(pfad); + akten.Add(new Paketakte(pfad, info.Length, + new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero), + Paketname.Lesen(pfad))); + } + catch (IOException) { /* Verschwunden, während gelesen wurde. */ } + } + + if (akten.Count == 0) return (frei, zahl); + + // Nur die Dateien nachschlagen, die wirklich dort liegen — nicht die + // ganze Mediathek. + var kennungen = akten.Select(a => a.Name?.DateiId).OfType().Distinct().ToList(); + var bekannt = await db.MediaFiles.AsNoTracking() + .Where(f => kennungen.Contains(f.Id)) + .Select(f => new { f.Id, f.ModifiedAt }) + .ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct); + + foreach (var a in Kratzplatzauswahl.Wegzuwerfen( + akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung)) + { + frei += Loeschen(a.Pfad); + zahl++; + } + + return (frei, zahl); + } + + /// Untertitel zu Dateien, die es nicht mehr gibt oder die sich geändert haben. + /// + /// + /// Keine Obergrenze, kein Alter. 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", kein Fehlschlag. Wer sie wegräumt, + /// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei + /// neu durchlesen, um wieder nichts zu finden. + /// + private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct) + { + var ordner = Path.Combine(pfade.Transcode, "untertitel"); + if (!Directory.Exists(ordner)) return (0, 0); + + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + + var gefunden = new List<(string Pfad, Guid Id, long Ms, int Spur)>(); + foreach (var pfad in Directory.EnumerateFiles(ordner, "*.vtt")) + { + var teile = Path.GetFileNameWithoutExtension(pfad).Split('-'); + if (teile.Length != 3 + || !Guid.TryParseExact(teile[0], "N", out var id) + || !long.TryParse(teile[1], out var ms) + || !int.TryParse(teile[2], out var spur)) + { + gefunden.Add((pfad, Guid.Empty, 0, -1)); + continue; + } + + gefunden.Add((pfad, id, ms, spur)); + } + + if (gefunden.Count == 0) return (0, 0); + + var kennungen = gefunden.Where(g => g.Spur >= 0).Select(g => g.Id).Distinct().ToList(); + var dateien = await db.MediaFiles.AsNoTracking() + .Where(f => kennungen.Contains(f.Id)) + .Select(f => new { f.Id, f.ModifiedAt }) + .ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct); + + // Eine Abfrage für alle Spuren, nicht eine je Datei. + var spuren = (await db.MediaStreams.AsNoTracking() + .Where(s => kennungen.Contains(s.MediaFileId) && s.Kind == Core.Library.MediaStreamKind.Subtitle) + .Select(s => new { s.MediaFileId, s.Index, s.Codec }) + .ToListAsync(ct)) + .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)) + .Select(s => (s.MediaFileId, s.Index)) + .ToHashSet(); + + long frei = 0; + var zahl = 0; + foreach (var g in gefunden) + { + var verwaist = g.Spur < 0 + || !dateien.TryGetValue(g.Id, out var ms) + || ms != g.Ms + || !spuren.Contains((g.Id, g.Spur)); + + if (!verwaist) continue; + frei += Loeschen(g.Pfad); + zahl++; + } + + return (frei, zahl); + } + + private long Loeschen(string pfad) + { + try + { + var gross = new FileInfo(pfad).Length; + File.Delete(pfad); + return gross; + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", pfad); + return 0; + } + } + + /// Die Belegung je Unterordner — für die Übersicht. + public IReadOnlyList Belegung() + { + var ordner = new[] + { + ("stroeme", stroeme.Wurzel), + ("packages", pakete.Ordner), + ("untertitel", Path.Combine(pfade.Transcode, "untertitel")), + }; + + return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2))) + .ToList(); + } + + private static int Zaehlen(string ordner) + { + try + { + return Directory.Exists(ordner) + ? Directory.EnumerateFiles(ordner, "*", SearchOption.AllDirectories).Count() + : 0; + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + return 0; + } + } +} diff --git a/server/src/Flaro.Api/PackageCoordinator.cs b/server/src/Flaro.Api/PackageCoordinator.cs index fd11912..0c43330 100644 --- a/server/src/Flaro.Api/PackageCoordinator.cs +++ b/server/src/Flaro.Api/PackageCoordinator.cs @@ -72,11 +72,80 @@ public sealed class PackageCoordinator( /// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und /// liefen gleichzeitig. /// - private const int Bauart = 2; + public const int Bauart = 2; + + /// Der Ordner, in dem die umgepackten Dateien liegen. + public string Ordner => Path.Combine(pfade.Transcode, "packages"); public string PathFor(Guid fileId, DateTimeOffset geändert) => - Path.Combine(pfade.Transcode, "packages", - $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}-{Bauart}.mp4"); + Path.Combine(Ordner, Paketname.Bauen(fileId, geändert, Bauart)); + + /// Wer gerade an einer Datei hängt — die wird nicht weggeräumt. + /// + /// + /// + /// Die Schonfrist allein genügt nicht. Ein Download über + /// Mobilfunk dauert Stunden, und zwischen zwei Bereichsanfragen kann + /// lange nichts kommen. Eine Datei, die in dieser Pause verschwindet, + /// kostet den ganzen Download: der laufende Strom überlebt ein + /// Entfernen unter Linux zwar, die Fortsetzung aber nicht — der + /// Client bekäme 202 und finge von vorn an. Genau das schliesst die + /// Beschreibung dieser Klasse oben aus. + /// + /// + private readonly ConcurrentDictionary _inBenutzung = new(); + + public bool IstInBenutzung(string pfad) => _inBenutzung.ContainsKey(pfad); + + /// Hält eine Datei fest, bis das Ergebnis entsorgt wird. + public IDisposable Benutzen(string pfad) + { + _inBenutzung.AddOrUpdate(pfad, 1, (_, alt) => alt + 1); + return new Griff(this, pfad); + } + + private sealed class Griff(PackageCoordinator wem, string pfad) : IDisposable + { + private bool _weg; + + public void Dispose() + { + if (_weg) return; + _weg = true; + wem._inBenutzung.AddOrUpdate(pfad, 0, (_, alt) => alt - 1); + wem._inBenutzung.TryRemove(new KeyValuePair(pfad, 0)); + } + } + + /// Hält fest, dass diese Datei gerade gebraucht wurde. + /// + /// + /// Die Änderungszeit als Benutzungsstempel. 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. Die Änderungszeit führt jedes, sie übersteht den Neustart, und + /// nach dem Bauen steht sie schon auf einem richtigen Wert. + /// + /// Gedrosselt. Eine Bereichsanfrage je Sekunde ist bei einem Film + /// normal; jedes Mal den Inode anzufassen wäre Schreiblast für nichts. + /// + public void AlsBenutztStempeln(string pfad) + { + try + { + // Nur im eigenen Ordner. Der Zweig für durchgereichte Dateien + // liefert eine Mediendatei aus — die fasst hier niemand an. + if (!Path.GetFullPath(pfad).StartsWith(Path.GetFullPath(Ordner), StringComparison.Ordinal)) return; + + var jetzt = clock.GetUtcNow(); + if (jetzt - File.GetLastWriteTimeUtc(pfad) < TimeSpan.FromMinutes(5)) return; + File.SetLastWriteTimeUtc(pfad, jetzt.UtcDateTime); + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + log.LogDebug(fehler, "{Datei} liess sich nicht stempeln.", pfad); + } + } public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert)); @@ -104,6 +173,13 @@ public sealed class PackageCoordinator( if (!File.Exists(datei.Path)) throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da."); + // **Erst aufräumen, dann messen.** Der Augenblick vor dem + // Umpacken ist der eine, in dem der Platz wirklich gebraucht + // wird — und `PlatzPruefen` sagt danach die Wahrheit statt + // „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen. + await bereich.ServiceProvider.GetRequiredService() + .LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken); + PlatzPruefen(datei.SizeBytes); var spuren = await db.MediaStreams.AsNoTracking() @@ -115,6 +191,13 @@ public sealed class PackageCoordinator( var plan = DeliveryDecider.ForDownload(datei.Container, gelesen); var ziel = PathFor(fileId, datei.ModifiedAt); + // **Erst unter `.teil`, dann umbenennen.** Vorher schrieb ffmpeg + // direkt auf den Endnamen; nach einem harten Abbruch lag dort + // eine abgeschnittene Datei, `IsReady` meldete „fertig", und sie + // ging so hinaus. Ein Aufräumer konnte vollständig und + // abgeschnitten nicht unterscheiden. Umbenennen ist auf + // demselben Dateisystem ein Zug und kann nicht halb geschehen. + var teil = ziel + ".teil"; Directory.CreateDirectory(Path.GetDirectoryName(ziel)!); AelteresWegraeumen(fileId, ziel); @@ -125,7 +208,10 @@ public sealed class PackageCoordinator( var umpacker = bereich.ServiceProvider.GetRequiredService(); var ergebnis = await umpacker.RunAsync( - new RemuxRequest(datei.Path, ziel, gelesen, plan), datei.Duration, fortschritt); + new RemuxRequest(datei.Path, teil, gelesen, plan), datei.Duration, fortschritt); + + if (ergebnis.Success) File.Move(teil, ziel, overwrite: true); + else { try { File.Delete(teil); } catch (IOException) { /* Kür */ } } _zustand[fileId] = ergebnis.Success ? new PackageState("ready", 1, _zustand[fileId].StartedAt, clock.GetUtcNow(), null) diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index b9756e4..c00a284 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -112,6 +112,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe. +builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage // ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. builder.Services.AddSingleton(); @@ -329,6 +332,13 @@ if (Directory.Exists(oberfläche)) // der häufige Fall, denn man startet ihn ja, um etwas zu schauen —, // wartete auf beides zugleich. Eine Minute reicht, damit die erste // Seite steht. + // **Zuerst aufräumen.** Nach einem harten Ende liegt jeder + // Stromordner noch da und jedes Teilstück eines abgebrochenen + // Umpackens — und beim Start ist sicher, dass niemand davon noch + // gebraucht wird. + _ = app.Services.GetRequiredService() + .LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping); + _ = Task.Run(async () => { try diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index b79482f..c68c70f 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -160,6 +160,13 @@ public sealed class ScanCoordinator( _ = bereich.ServiceProvider.GetRequiredService() .NachtragenAsync(CancellationToken.None); + // **Und aufräumen.** Ein Scan ist der Augenblick, in dem + // Verwaiste entstehen: Dateien verschwinden, Änderungszeiten + // wandern, und die dazugehörigen Pakete und Untertitel gehören + // zu einem Stand, den es nicht mehr gibt. + _ = bereich.ServiceProvider.GetRequiredService() + .LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan); + _zustand[libraryId] = _zustand[libraryId] with { Status = "finished", diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs index 7f2b464..85d8675 100644 --- a/server/src/Flaro.Api/Streamkoordinator.cs +++ b/server/src/Flaro.Api/Streamkoordinator.cs @@ -354,6 +354,82 @@ public sealed class Streamkoordinator( } } + /// Der Ordner, in dem die Segmente liegen. + public string Wurzel => Path.Combine(pfade.Transcode, "stroeme"); + + /// Räumt alles weg, was unter der Wurzel liegt. + /// + /// + /// Beim Herunterfahren und beim Hochfahren. Die Ordnernamen sind + /// Sitzungskennungen aus dem Arbeitsspeicher; nach einem Start gibt es + /// keine einzige Sitzung, also ist jeder Ordner verwaist. Bisher geschah + /// das nur beim geordneten Ende — nach einem harten Abbruch blieb alles + /// liegen, und niemand kam je wieder darauf zurück. + /// + public long WurzelLeeren() + { + if (!Directory.Exists(Wurzel)) return 0; + + var frei = Belegung(Wurzel); + try { Directory.Delete(Wurzel, recursive: true); } + catch (IOException fehler) + { + log.LogDebug(fehler, "Der Stromordner {Pfad} liess sich nicht leeren.", Wurzel); + return 0; + } + + return frei; + } + + /// Räumt Ordner weg, zu denen es keine Sitzung mehr gibt. + /// + /// + /// Nicht bloss Symmetrie zum Herunterfahren. Scheitert das + /// Löschen in Abraeumen an einem IOException, ist die + /// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer + /// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein + /// zweiter Prozess denselben Kratzplatz benutzt: ein aktiver Ordner + /// bekommt alle paar Sekunden ein Segment geschrieben. + /// + public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt) + { + AbgelaufeneWegraeumen(); + if (!Directory.Exists(Wurzel)) return 0; + + long frei = 0; + foreach (var ordner in Directory.EnumerateDirectories(Wurzel)) + { + var name = Path.GetFileName(ordner); + if (Guid.TryParse(name, out var kennung) && _sitzungen.ContainsKey(kennung)) continue; + if (jetzt - new DateTimeOffset(Directory.GetLastWriteTimeUtc(ordner), TimeSpan.Zero) < Geduld) continue; + + var gross = Belegung(ordner); + try { Directory.Delete(ordner, recursive: true); frei += gross; } + catch (IOException fehler) + { + log.LogDebug(fehler, "Der verwaiste Ordner {Pfad} liess sich nicht löschen.", ordner); + } + } + + return frei; + } + + /// Wie viele Bytes unter einem Ordner liegen. + public static long Belegung(string ordner) + { + try + { + return Directory.Exists(ordner) + ? Directory.EnumerateFiles(ordner, "*", SearchOption.AllDirectories) + .Sum(d => { try { return new FileInfo(d).Length; } catch (IOException) { return 0; } }) + : 0; + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + return 0; + } + } + /// Beim Herunterfahren bleibt nichts laufen und nichts liegen. public void Dispose() { @@ -362,11 +438,6 @@ public sealed class Streamkoordinator( // **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende // steht der Ordner noch da; ihn beim nächsten Herunterfahren zu // übersehen hiesse, dass der Kratzplatz nur wächst. - try - { - var wurzel = Path.Combine(pfade.Transcode, "stroeme"); - if (Directory.Exists(wurzel)) Directory.Delete(wurzel, recursive: true); - } - catch (IOException) { } + WurzelLeeren(); } } diff --git a/server/src/Flaro.Core/Delivery/Kratzplatzregeln.cs b/server/src/Flaro.Core/Delivery/Kratzplatzregeln.cs new file mode 100644 index 0000000..ddb97fb --- /dev/null +++ b/server/src/Flaro.Core/Delivery/Kratzplatzregeln.cs @@ -0,0 +1,130 @@ +namespace Flaro.Core.Delivery; + +/// Wann eine umgepackte Datei weggeräumt wird. +/// +/// +/// +/// Der Anlass: 39 Gigabyte. 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. Es gibt im ganzen +/// Server keinen Hintergrunddienst und keinen Takt; also muss die Regel +/// entscheidbar sein, ohne dass jemand mitschreibt. +/// +/// +/// Warum Obergrenze und Alter. Ein Alter allein löst den Fall nicht: +/// die 39 Gigabyte waren gültige, aktuelle Dateien, jede für sich +/// berechtigt. Nur eine Obergrenze deckelt das. Eine Obergrenze allein ist +/// zu grob: ein Topf knapp unter der Grenze, in dem seit Wochen nichts +/// gebraucht wurde, besetzt ein Volume, das anderes braucht. +/// +/// +/// Wie viel insgesamt liegen bleiben darf; 0 heisst unbegrenzt. +/// Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; Zero heisst nie. +/// Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt. +public sealed record Kratzplatzregeln( + long ObergrenzeBytes, + TimeSpan Unbenutzt, + TimeSpan Schonfrist) +{ + /// + /// Zwanzig Gigabyte: eine Quelldatei sind zwei bis zwanzig, damit liegen + /// zwei bis fünf Filme vor — genug, dass ein abgebrochener und ein + /// zweiter Anlauf dieselbe Datei treffen. Sieben Tage statt einem, damit + /// eine Urlaubswoche keine angefangene Mitnahme kostet. Dreissig Minuten + /// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen + /// Neustart, der den Benutzungszähler verliert. + /// + public static Kratzplatzregeln Vorgabe { get; } = + new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30)); + + /// Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht. + /// + /// + /// Eine zu kurze Schonfrist ist gefährlicher als gar keine. Wer + /// sie auf eine Minute stellt, löscht sich mitten im Herunterladen die + /// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte + /// gelten als „aus". + /// + public static Kratzplatzregeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) => + new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024), + unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)), + TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440))); +} + +/// Eine Datei im Kratzplatz, so wie sie dort liegt. +/// +/// +/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst. +/// Und nicht die Zugriffszeit: die führt nicht jedes Dateisystem +/// (noatime), die Änderungszeit schon. Jede Auslieferung stempelt sie +/// neu; nach dem Bauen steht sie ohnehin richtig. +/// +public readonly record struct Paketakte( + string Pfad, + long Groesse, + DateTimeOffset Benutzt, + Paketname.Teile? Name); + +/// Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte. +public static class Kratzplatzauswahl +{ + /// Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek. + /// Wie der Server heute baut. + /// Ob gerade jemand diese Datei herunterlädt. + public static IReadOnlyList Wegzuwerfen( + IReadOnlyList akten, + IReadOnlyDictionary bekannt, + int bauart, + Kratzplatzregeln regeln, + DateTimeOffset jetzt, + Func inBenutzung) + { + var weg = new List(); + var behalten = new List(); + + // **Stufe eins: Verwaiste, unabhängig vom Platz.** Diese Dateien + // werden nie wieder ausgeliefert — sie zu behalten ist keine + // Abwägung, sondern ein Versehen. + foreach (var a in akten) + { + if (Verwaist(a, bekannt, bauart)) weg.Add(a); + else behalten.Add(a); + } + + // Was gerade geholt wird oder eben erst gebraucht wurde, ist tabu. + bool Geschuetzt(Paketakte a) => + inBenutzung(a.Pfad) || jetzt - a.Benutzt < regeln.Schonfrist; + + // **Stufe zwei: zu lange nicht gebraucht.** Gilt auch, wenn noch + // Platz wäre. + if (regeln.Unbenutzt > TimeSpan.Zero) + { + var alt = behalten + .Where(a => !Geschuetzt(a) && jetzt - a.Benutzt > regeln.Unbenutzt) + .ToList(); + weg.AddRange(alt); + behalten.RemoveAll(alt.Contains); + } + + // **Stufe drei: die Obergrenze, Ältestes zuerst.** + if (regeln.ObergrenzeBytes > 0) + { + var summe = behalten.Sum(a => a.Groesse); + foreach (var a in behalten.Where(a => !Geschuetzt(a)).OrderBy(a => a.Benutzt).ToList()) + { + if (summe <= regeln.ObergrenzeBytes) break; + weg.Add(a); + behalten.Remove(a); + summe -= a.Groesse; + } + } + + return weg; + } + + private static bool Verwaist(Paketakte a, IReadOnlyDictionary bekannt, int bauart) => + a.Name is not { } n + || n.Bauart != bauart + || !bekannt.TryGetValue(n.DateiId, out var ms) + || ms != n.GeaendertMs; +} diff --git a/server/src/Flaro.Core/Delivery/Paketname.cs b/server/src/Flaro.Core/Delivery/Paketname.cs new file mode 100644 index 0000000..a744d5f --- /dev/null +++ b/server/src/Flaro.Core/Delivery/Paketname.cs @@ -0,0 +1,56 @@ +using System.Globalization; + +namespace Flaro.Core.Delivery; + +/// Der Name einer umgepackten Datei — gebaut und wieder gelesen. +/// +/// +/// +/// Warum der Name alles trägt. Im Kratzplatz liegen Dateien, zu denen +/// es keine Tabelle gibt. Ein Aufräumer muss allein am Namen erkennen können, +/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur, +/// alles zu behalten oder alles wegzuwerfen. +/// +/// +/// Drei Angaben. Die Kennung der Mediendatei, der Änderungszeitpunkt +/// der Quelle und die Bauart. Die ersten beiden sagen „gehört zu dieser Datei +/// in genau diesem Stand", die dritte „gebaut mit diesen Regeln". Ändert sich +/// eine davon, heisst das Ergebnis anders und die alte Fassung wird nicht +/// mehr ausgeliefert. +/// +/// +/// Gebaut wird unter .teil. Vorher schrieb ffmpeg direkt auf +/// den Endnamen. Nach einem harten Abbruch lag dort eine abgeschnittene +/// Datei, die als fertig galt und ausgeliefert wurde — und ein Aufräumer +/// konnte vollständig und abgeschnitten nicht unterscheiden. Umbenennen ist +/// auf demselben Dateisystem ein Zug und kann nicht halb geschehen. +/// +/// +public static class Paketname +{ + public const string Endung = ".mp4"; + public const string Teilendung = ".mp4.teil"; + + public static string Bauen(Guid dateiId, DateTimeOffset geaendert, int bauart) => + string.Create(CultureInfo.InvariantCulture, + $"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}-{bauart}{Endung}"); + + /// Was in einem Namen steht — oder null, wenn er keiner ist. + public readonly record struct Teile(Guid DateiId, long GeaendertMs, int Bauart); + + /// Liest einen Dateinamen; der Pfad davor stört nicht. + public static Teile? Lesen(string pfadOderName) + { + var name = Path.GetFileName(pfadOderName); + if (!name.EndsWith(Endung, StringComparison.OrdinalIgnoreCase)) return null; + + var kern = name[..^Endung.Length].Split('-'); + if (kern.Length != 3) return null; + + if (!Guid.TryParseExact(kern[0], "N", out var id)) return null; + if (!long.TryParse(kern[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return null; + if (!int.TryParse(kern[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bauart)) return null; + + return new Teile(id, ms, bauart); + } +} diff --git a/server/src/Flaro.Media/RemuxArguments.cs b/server/src/Flaro.Media/RemuxArguments.cs index 0cf464c..08087f6 100644 --- a/server/src/Flaro.Media/RemuxArguments.cs +++ b/server/src/Flaro.Media/RemuxArguments.cs @@ -118,6 +118,16 @@ public static class RemuxArguments argumente.Add("-movflags"); argumente.Add("+faststart"); + // **Das Format ausdrücklich, nicht über die Endung.** + // + // ffmpeg wählt den Muxer am Dateinamen. Gebaut wird aber unter + // `.teil` — erst nach Erfolg wird umbenannt, damit ein harter + // Abbruch keine abgeschnittene Datei unter dem fertigen Namen + // hinterlässt. Ohne diese Zeile antwortet ffmpeg darauf mit „Unable + // to choose an output format", und zwar bei jeder Datei. + argumente.Add("-f"); + argumente.Add("mp4"); + argumente.Add(anfrage.TargetPath); return argumente; } diff --git a/server/tests/Flaro.Api.Tests/KratzplatzTests.cs b/server/tests/Flaro.Api.Tests/KratzplatzTests.cs new file mode 100644 index 0000000..ce93bd6 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/KratzplatzTests.cs @@ -0,0 +1,322 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api; +using Flaro.Api.Endpoints; +using Flaro.Core.Delivery; + +namespace Flaro.Api.Tests; + +/// Was der Server auf dem Kratzplatz wegräumt — und was nicht. +/// +/// +/// +/// Der Anlass: 39 Gigabyte. Für jede Mitnahme baut der Server eine +/// MP4 in Quellgrösse und löschte sie nie. Stromordner verschwanden nur +/// beim geordneten Herunterfahren, Untertitel gar nicht. +/// +/// +/// Hier wird die Verdrahtung geprüft, nicht die Regel — die steht +/// ohne Platte und ohne Server in +/// Flaro.Core.Tests/KratzplatzregelTests. Was hier zählt: findet der +/// Aufräumer die Dateien, erkennt er, was zur Mediathek gehört, und lässt +/// er das Gültige stehen. +/// +/// +[TestFixture] +public sealed class KratzplatzTests +{ + private string _bestand = ""; + + /// Ein fester Änderungszeitpunkt, damit der Name vorhersagbar ist. + private static readonly DateTimeOffset Gestempelt = + new(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-kratz-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ } + } + + /// Ein Server mit einem eingelesenen Film und bekanntem Kratzplatz. + private async Task<(HttpClient Client, string Kratzplatz, Guid DateiId)> Aufgesetzt(FlaroServer server) + { + var voll = Path.Combine(_bestand, "Probe (2026)", "Probe (2026).mkv"); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + await File.WriteAllBytesAsync(voll, new byte[4096]); + // **Der Zeitstempel wird gesetzt, nicht abgelesen.** Der Name einer + // umgepackten Datei trägt ihn; ohne festen Wert liesse sich kein + // gültiger Name bauen, und der Test prüfte nur das Löschen. + File.SetLastWriteTimeUtc(voll, Gestempelt.UtcDateTime); + + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State is "finished") break; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + + var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); + var kratz = orte!.First(o => o.Variable == "FLARO_TRANSCODE").Pfad; + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var film = await client.GetFromJsonAsync($"/api/v1/movies/{filme!.Items[0].Id}"); + + return (client, kratz, film!.Files[0].Id); + } + + private static string Legen(string ordner, string name, int kilobyte = 4) + { + Directory.CreateDirectory(ordner); + var pfad = Path.Combine(ordner, name); + File.WriteAllBytes(pfad, new byte[kilobyte * 1024]); + return pfad; + } + + private static Task Aufraeumen(HttpClient client) => + client.PostAsync("/api/v1/system/storage/transcode/cleanup", null) + .ContinueWith(a => a.Result.Content.ReadFromJsonAsync()).Unwrap(); + + /// Ein Paket, zu dem es keinen Eintrag mehr gibt, fliegt. + [Test] + public async Task Ein_verwaistes_Paket_ist_nach_dem_Aufraeumen_weg() + { + using var server = new FlaroServer(); + var (client, kratz, _) = await Aufgesetzt(server); + + var fremd = Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(Guid.CreateVersion7(), Gestempelt, PackageCoordinator.Bauart)); + + var bericht = await Aufraeumen(client); + + Assert.Multiple(() => + { + Assert.That(File.Exists(fremd), Is.False); + Assert.That(bericht!.Frei, Is.GreaterThan(0), "und es wird gemeldet, was freigeworden ist"); + }); + } + + /// Ein Paket, das noch gilt, bleibt liegen. + /// + /// + /// Die wichtigere Hälfte. Ein Aufräumer, der zu viel wegwirft, + /// kostet bei jedem zweiten Abspielen die volle Umpackzeit — und das + /// merkt man erst im Betrieb. + /// + [Test] + public async Task Ein_gueltiges_Paket_bleibt_liegen() + { + using var server = new FlaroServer(); + var (client, kratz, dateiId) = await Aufgesetzt(server); + + var gueltig = Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart)); + + await Aufraeumen(client); + + Assert.That(File.Exists(gueltig), Is.True); + } + + /// Ein Paket zum alten Stand derselben Datei fliegt. + [Test] + public async Task Ein_Paket_mit_altem_Zeitstempel_ist_weg() + { + using var server = new FlaroServer(); + var (client, kratz, dateiId) = await Aufgesetzt(server); + + var veraltet = Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(dateiId, Gestempelt.AddDays(-5), PackageCoordinator.Bauart)); + + await Aufraeumen(client); + + Assert.That(File.Exists(veraltet), Is.False); + } + + /// Was eine frühere Fassung des Servers gebaut hat, fliegt. + [Test] + public async Task Ein_Paket_fremder_Bauart_ist_weg() + { + using var server = new FlaroServer(); + var (client, kratz, dateiId) = await Aufgesetzt(server); + + var alteBauart = Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart - 1)); + + await Aufraeumen(client); + + Assert.That(File.Exists(alteBauart), Is.False); + } + + /// Ein liegengebliebenes Teilstück fliegt bedingungslos. + /// + /// + /// Es entsteht nur beim Bauen; wer gerade baut, hält die Datei fest. Ein + /// liegengebliebenes ist das Überbleibsel eines harten Abbruchs — und + /// genau dafür gibt es die Endung. + /// + [Test] + public async Task Ein_Teilstueck_ist_weg() + { + using var server = new FlaroServer(); + var (client, kratz, dateiId) = await Aufgesetzt(server); + + var teil = Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart) + ".teil"); + + await Aufraeumen(client); + + Assert.That(File.Exists(teil), Is.False); + } + + /// Ein verwaister Stromordner ist nach dem Aufräumen weg. + [Test] + public async Task Ein_verwaister_Stromordner_ist_weg() + { + using var server = new FlaroServer(); + var (client, kratz, _) = await Aufgesetzt(server); + + var ordner = Path.Combine(kratz, "stroeme", Guid.CreateVersion7().ToString()); + Legen(ordner, "000.m4s"); + // Älter als die Geduld, sonst gilt er als eben erst beschrieben. + Directory.SetLastWriteTimeUtc(ordner, DateTime.UtcNow.AddHours(-1)); + + await Aufraeumen(client); + + Assert.That(Directory.Exists(ordner), Is.False); + } + + /// Ein eben beschriebener Stromordner bleibt. + /// + /// + /// Die Sitzung lebt im Arbeitsspeicher; dass ein Ordner gerade Segmente + /// bekommt, sieht man nur an seiner Änderungszeit. Ohne diese Prüfung + /// risse ein Aufräumlauf einer laufenden Wiedergabe den Boden weg. + /// + [Test] + public async Task Ein_frischer_Stromordner_bleibt() + { + using var server = new FlaroServer(); + var (client, kratz, _) = await Aufgesetzt(server); + + var ordner = Path.Combine(kratz, "stroeme", Guid.CreateVersion7().ToString()); + Legen(ordner, "000.m4s"); + + await Aufraeumen(client); + + Assert.That(Directory.Exists(ordner), Is.True); + } + + /// Eine Untertiteldatei zu einem gelöschten Titel fliegt. + [Test] + public async Task Eine_verwaiste_Untertiteldatei_ist_weg() + { + using var server = new FlaroServer(); + var (client, kratz, _) = await Aufgesetzt(server); + + var weg = Legen(Path.Combine(kratz, "untertitel"), + $"{Guid.CreateVersion7():N}-{Gestempelt.ToUnixTimeMilliseconds()}-2.vtt"); + + await Aufraeumen(client); + + Assert.That(File.Exists(weg), Is.False); + } + + /// Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl. + [Test] + public async Task Die_Belegung_nennt_die_drei_Unterordner() + { + using var server = new FlaroServer(); + var (client, kratz, dateiId) = await Aufgesetzt(server); + Legen(Path.Combine(kratz, "packages"), + Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart), kilobyte: 8); + + var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); + var kratzplatz = orte!.First(o => o.Variable == "FLARO_TRANSCODE"); + + Assert.Multiple(() => + { + Assert.That(kratzplatz.Teile, Is.Not.Null); + Assert.That(kratzplatz.Teile!.Select(t => t.Name), + Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" })); + var pakete = kratzplatz.Teile!.First(t => t.Name == "packages"); + Assert.That(pakete.Dateien, Is.EqualTo(1)); + Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners"); + Assert.That(orte!.First(o => o.Variable == "FLARO_DATA").Teile, Is.Null, + "nur der Kratzplatz wird aufgeschlüsselt"); + }); + } + + [Test] + public async Task Aufraeumen_verlangt_Administratorrechte() + { + using var server = new FlaroServer(); + var (admin, _, _) = await Aufgesetzt(server); + var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang"); + + var antwort = await gast.PostAsync("/api/v1/system/storage/transcode/cleanup", null); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + /* ---------- Die Einstellungen ---------- */ + + [Test] + public async Task Die_Vorgaben_stehen_ohne_dass_jemand_etwas_gesetzt_hat() + { + using var server = new FlaroServer(); + var (client, _, _) = await Aufgesetzt(server); + + var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); + + Assert.Multiple(() => + { + Assert.That(stand!.CapGb, Is.EqualTo(20).Within(0.01)); + Assert.That(stand.UnusedDays, Is.EqualTo(7)); + Assert.That(stand.GraceMinutes, Is.EqualTo(30)); + }); + } + + [Test] + public async Task Eine_gesetzte_Obergrenze_kommt_zurueck() + { + using var server = new FlaroServer(); + var (client, _, _) = await Aufgesetzt(server); + + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(5, null, null)); + var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); + + Assert.Multiple(() => + { + Assert.That(stand!.CapGb, Is.EqualTo(5).Within(0.01)); + Assert.That(stand.UnusedDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt"); + }); + } + + [Test] + public async Task Eine_unsinnige_Schonfrist_wird_zurechtgebogen() + { + using var server = new FlaroServer(); + var (client, _, _) = await Aufgesetzt(server); + + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(null, null, 1)); + var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); + + Assert.That(stand!.GraceMinutes, Is.EqualTo(5), + "eine Minute löschte die Datei mitten im Herunterladen weg"); + } +} diff --git a/server/tests/Flaro.Core.Tests/KratzplatzregelTests.cs b/server/tests/Flaro.Core.Tests/KratzplatzregelTests.cs new file mode 100644 index 0000000..e635b02 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/KratzplatzregelTests.cs @@ -0,0 +1,247 @@ +using Flaro.Core.Delivery; + +namespace Flaro.Core.Tests; + +/// Welche umgepackten Dateien weggeräumt werden. +/// +/// +/// Der Anlass: 39 Gigabyte Kratzplatz. Für jede Mitnahme baut der +/// Server eine MP4 in Quellgrösse und löschte sie nie. Hier steht die Regel, +/// die das beendet — ohne Platte, ohne Server, damit die Entscheidung selbst +/// nachlesbar ist und nicht nur ihr Ergebnis. +/// +public class KratzplatzregelTests +{ + private static readonly DateTimeOffset Jetzt = new(2026, 10, 8, 12, 0, 0, TimeSpan.Zero); + private const int Bauart = 2; + + private static readonly Guid EinsId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private static readonly Guid ZweiId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + private const long Gigabyte = 1024L * 1024 * 1024; + + /// Eine Datei, wie sie im Kratzplatz läge. + private static Paketakte Akte( + Guid id, long gigabyte, TimeSpan vorWieLange, long? geaendertMs = null, int bauart = Bauart) + { + var ms = geaendertMs ?? 1_000; + var name = Paketname.Bauen(id, DateTimeOffset.FromUnixTimeMilliseconds(ms), bauart); + return new Paketakte($"/kratz/packages/{name}", gigabyte * Gigabyte, + Jetzt - vorWieLange, Paketname.Lesen(name)); + } + + /// Die Mediathek kennt beide Dateien in genau diesem Stand. + private static readonly Dictionary Bekannt = + new() { [EinsId] = 1_000, [ZweiId] = 1_000 }; + + private static IReadOnlyList Weg( + IReadOnlyList akten, + Kratzplatzregeln? regeln = null, + IReadOnlyDictionary? bekannt = null, + Func? inBenutzung = null) => + Kratzplatzauswahl.Wegzuwerfen( + akten, bekannt ?? Bekannt, Bauart, + regeln ?? Kratzplatzregeln.Vorgabe, Jetzt, + inBenutzung ?? (_ => false)) + .Select(a => a.Pfad).ToList(); + + /// Was nie wieder ausgeliefert wird, geht — auch wenn Platz wäre. + [Test] + public void Eine_Datei_ohne_Eintrag_in_der_Mediathek_geht_weg() + { + var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2)) }; + + Assert.That(Weg(akten, bekannt: new Dictionary()), Has.Count.EqualTo(1), + "der Titel ist aus der Mediathek verschwunden, das Paket ist wertlos"); + } + + [Test] + public void Ein_veralteter_Zeitstempel_im_Namen_gilt_als_verwaist() + { + var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2), geaendertMs: 999) }; + + Assert.That(Weg(akten), Has.Count.EqualTo(1), + "die Quelle wurde ersetzt; dieses Paket gehört zum alten Stand"); + } + + [Test] + public void Eine_andere_Bauart_gilt_als_verwaist() + { + var akten = new[] { Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1) }; + + Assert.That(Weg(akten), Has.Count.EqualTo(1), + "so gebaut würde der Server es heute nicht mehr ausliefern"); + } + + [Test] + public void Ein_unleserlicher_Name_gilt_als_verwaist() + { + var akten = new[] + { + new Paketakte("/kratz/packages/irgendwas.mp4", Gigabyte, Jetzt, Paketname.Lesen("irgendwas.mp4")), + }; + + Assert.That(Weg(akten), Has.Count.EqualTo(1)); + } + + /// Die Schonfrist schützt auch Verwaiste nicht. + /// + /// + /// Sie schützt das, was gleich gebraucht wird. Eine Datei, die niemand + /// mehr ausliefern kann, wird nicht gleich gebraucht — sie frisch + /// liegenzulassen hiesse, auf etwas zu warten, das nicht kommt. + /// + [Test] + public void Eine_frische_Verwaiste_geht_trotzdem() + { + var akten = new[] { Akte(EinsId, 1, TimeSpan.Zero, bauart: 1) }; + + Assert.That(Weg(akten), Has.Count.EqualTo(1)); + } + + [Test] + public void Unter_der_Obergrenze_bleibt_alles_liegen() + { + var akten = new[] + { + Akte(EinsId, 5, TimeSpan.FromHours(2)), + Akte(ZweiId, 5, TimeSpan.FromHours(3)), + }; + + Assert.That(Weg(akten), Is.Empty); + } + + /// Über der Obergrenze geht das am längsten Ungenutzte zuerst. + [Test] + public void Ueber_der_Obergrenze_geht_das_aelteste_zuerst() + { + var jung = Akte(EinsId, 15, TimeSpan.FromHours(2)); + var alt = Akte(ZweiId, 15, TimeSpan.FromHours(20)); + + var weg = Weg([jung, alt]); + + Assert.Multiple(() => + { + Assert.That(weg, Has.Count.EqualTo(1), "eines von beiden reicht, um unter 20 GB zu kommen"); + Assert.That(weg[0], Is.EqualTo(alt.Pfad)); + }); + } + + [Test] + public void Was_in_der_Schonfrist_liegt_bleibt_auch_ueber_der_Obergrenze() + { + var eben = Akte(EinsId, 15, TimeSpan.FromMinutes(1)); + var auchEben = Akte(ZweiId, 15, TimeSpan.FromMinutes(2)); + + Assert.That(Weg([eben, auchEben]), Is.Empty, + "beide wurden gerade gebraucht — wegräumen hiesse, jemandem den Download abzuschneiden"); + } + + [Test] + public void Was_in_Benutzung_ist_bleibt_auch_ueber_der_Obergrenze() + { + var laeuft = Akte(EinsId, 15, TimeSpan.FromDays(2)); + var daneben = Akte(ZweiId, 15, TimeSpan.FromHours(2)); + + var weg = Weg([laeuft, daneben], inBenutzung: p => p == laeuft.Pfad); + + Assert.That(weg, Does.Not.Contain(laeuft.Pfad), + "ein Download über Mobilfunk kann Stunden dauern, ohne dass eine Anfrage kommt"); + } + + /// Unbenutztes geht auch, wenn noch Platz wäre. + [Test] + public void Lange_Unbenutzte_gehen_auch_unter_der_Obergrenze() + { + var akten = new[] { Akte(EinsId, 1, TimeSpan.FromDays(30)) }; + + Assert.That(Weg(akten), Has.Count.EqualTo(1)); + } + + [Test] + public void Eine_Obergrenze_von_null_heisst_unbegrenzt() + { + var regeln = Kratzplatzregeln.Vorgabe with { ObergrenzeBytes = 0 }; + var akten = new[] + { + Akte(EinsId, 500, TimeSpan.FromHours(2)), + Akte(ZweiId, 500, TimeSpan.FromHours(3)), + }; + + Assert.That(Weg(akten, regeln), Is.Empty); + } + + [Test] + public void Ohne_Altersabbau_bleibt_auch_sehr_Altes_liegen() + { + var regeln = Kratzplatzregeln.Vorgabe with { Unbenutzt = TimeSpan.Zero }; + + Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromDays(400))], regeln), Is.Empty); + } + + /// Auch mit abgeschalteten Regeln fliegt Verwaistes. + /// + /// + /// „Unbegrenzt" heisst „so viel du willst", nicht „sammle auch Müll". + /// + [Test] + public void Verwaistes_geht_auch_bei_abgeschalteten_Grenzen() + { + var regeln = new Kratzplatzregeln(0, TimeSpan.Zero, TimeSpan.FromMinutes(30)); + + Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1)], regeln), Has.Count.EqualTo(1)); + } + + [Test] + public void Eine_zu_kurze_Schonfrist_wird_hochgesetzt() + { + var regeln = Kratzplatzregeln.Geprueft(20, 7, 1); + + Assert.That(regeln.Schonfrist, Is.EqualTo(TimeSpan.FromMinutes(5)), + "eine Minute löschte die Datei mitten im Herunterladen weg"); + } + + [Test] + public void Null_und_negative_Werte_heissen_abgeschaltet() + { + var regeln = Kratzplatzregeln.Geprueft(0, -3, 30); + + Assert.Multiple(() => + { + Assert.That(regeln.ObergrenzeBytes, Is.Zero); + Assert.That(regeln.Unbenutzt, Is.EqualTo(TimeSpan.Zero)); + }); + } +} + +/// Der Name einer umgepackten Datei, in beide Richtungen. +public class PaketnameTests +{ + [Test] + public void Ein_gebauter_Name_laesst_sich_wieder_lesen() + { + var id = Guid.CreateVersion7(); + var geaendert = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_123); + + var gelesen = Paketname.Lesen(Paketname.Bauen(id, geaendert, 2)); + + Assert.That(gelesen, Is.EqualTo(new Paketname.Teile(id, 1_700_000_000_123, 2))); + } + + [Test] + public void Der_Pfad_davor_stoert_nicht() + { + var id = Guid.CreateVersion7(); + var name = Paketname.Bauen(id, DateTimeOffset.FromUnixTimeMilliseconds(5), 2); + + Assert.That(Paketname.Lesen($"/kratz/packages/{name}")?.DateiId, Is.EqualTo(id)); + } + + [TestCase("irgendwas.mp4")] + [TestCase("abc-123-2.mp4")] + [TestCase("00000000000000000000000000000001-123.mp4")] + [TestCase("00000000000000000000000000000001-123-2.teil")] + [TestCase("00000000000000000000000000000001-nichtzahl-2.mp4")] + public void Ein_fremder_Name_ergibt_nichts(string name) => + Assert.That(Paketname.Lesen(name), Is.Null); +} diff --git a/server/tests/Flaro.Media.Tests/RemuxTests.cs b/server/tests/Flaro.Media.Tests/RemuxTests.cs index 9d33613..595ebe2 100644 --- a/server/tests/Flaro.Media.Tests/RemuxTests.cs +++ b/server/tests/Flaro.Media.Tests/RemuxTests.cs @@ -188,6 +188,38 @@ public class RemuxTests Assert.That(File.Exists(ziel), Is.False); } + /// Der Umpacker schreibt dorthin, wohin man ihn schickt. + /// + /// + /// Warum das eine eigene Prüfung ist. Der Koordinator lässt jetzt + /// unter .teil bauen und benennt erst nach Erfolg um. Vorher + /// schrieb ffmpeg direkt auf den Endnamen — nach einem harten Abbruch lag + /// dort eine abgeschnittene Datei, die als fertig galt und ausgeliefert + /// wurde, und ein Aufräumer konnte vollständig und abgeschnitten nicht + /// unterscheiden. Dass der Zielpfad wirklich durchgereicht wird und + /// nicht irgendwo abgeleitet, hält diese Prüfung fest. + /// + [Test] + public async Task Das_Ergebnis_landet_genau_am_angegebenen_Pfad() + { + var quelle = Path.Combine(_ordner, "pfadprobe.mkv"); + Testdateien.MitZweiTonspuren(quelle); + + var davor = await _ffprobe.ProbeAsync(quelle); + var plan = DeliveryDecider.ForDownload(davor!.Container, davor.Streams); + var ziel = Path.Combine(_ordner, "ergebnis.mp4.teil"); + + var ergebnis = await _umpacker.RunAsync( + new RemuxRequest(quelle, ziel, davor.Streams, plan), davor.Duration); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Success, Is.True, ergebnis.Error); + Assert.That(File.Exists(ziel), Is.True, "unter dem Teilnamen, nicht unter dem Endnamen"); + Assert.That(File.Exists(Path.Combine(_ordner, "ergebnis.mp4")), Is.False); + }); + } + /// Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 7e05fbd..dcf7eff 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1016,11 +1016,35 @@ export const statistik = (wochen = 12) => export type Speicherort = { name: string; variable: string; pfad: string; belegt: number; frei: number | null; bilder: number | null; + /** Nur beim Kratzplatz: die Unterordner einzeln. */ + teile: { name: string; frei: number; dateien: number }[] | null; }; /** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */ export const speicherorte = () => anfrage('/system/storage'); +export type Aufraeumbericht = { + frei: number; dateien: number; + teile: { name: string; frei: number; dateien: number }[]; +}; + +/** Räumt den Kratzplatz auf — Verwaistes immer, der Rest nach der Obergrenze. + * + * POST und nicht DELETE wie beim Bildspeicher: dort wird ein Ordner geleert, + * hier eine Regel angewandt. */ +export const kratzplatzAufraeumen = () => + anfrage('/system/storage/transcode/cleanup', { method: 'POST' }); + +export type Wartungseinstellung = { capGb: number; unusedDays: number; graceMinutes: number }; + +export const wartungseinstellung = () => + anfrage('/settings/maintenance'); + +export const wartungSetzen = (wunsch: Partial) => + anfrage('/settings/maintenance', { + method: 'PUT', body: JSON.stringify(wunsch), + }); + /** Leert den Bildspeicher. Ungefährlich — Bilder werden bei Bedarf neu geholt. */ export const bildspeicherLeeren = () => anfrage<{ freed: number }>('/system/storage/images', { method: 'DELETE' }); diff --git a/web/app/src/views/einstellungen/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts index 2d66c40..f7634f2 100644 --- a/web/app/src/views/einstellungen/uebersicht.ts +++ b/web/app/src/views/einstellungen/uebersicht.ts @@ -1,7 +1,7 @@ import { doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, aufmacherhoeheSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, - bildspeicherLeeren, type Wiedergabe, + bildspeicherLeeren, kratzplatzAufraeumen, type Wiedergabe, } from '../../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util'; import { dialog } from '../../dialog'; @@ -296,6 +296,38 @@ export function speicherbereich(): HTMLElement { + `Wird gesetzt über ${esc(o.variable)}.`; oben.appendChild(text); + // **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. Die Dateizahl steht dabei, weil + // bei den Untertiteln die Anzahl das Problem ist und nicht die Grösse. + if (o.teile) { + const auf = document.createElement('span'); + auf.className = 'winzig'; + auf.textContent = o.teile + .map(t => `${t.name} ${groesse(t.frei)} (${t.dateien})`) + .join(' · '); + text.appendChild(auf); + + const raeumen = knopf('Jetzt aufräumen', 'btn'); + raeumen.onclick = async () => { + raeumen.disabled = true; + try { + const bericht = await kratzplatzAufraeumen(); + await laden(); + const gemeldet = document.createElement('p'); + gemeldet.className = 'hinweis'; + gemeldet.textContent = bericht.frei > 0 + ? `${groesse(bericht.frei)} freigegeben (${bericht.dateien} Datei(en)).` + : 'Es gab nichts wegzuräumen.'; + liste.prepend(gemeldet); + } catch (fehler) { + d.appendChild(fehlerzeile(meldung(fehler))); + raeumen.disabled = false; + } + }; + oben.appendChild(raeumen); + } + // Leeren gibt es nur dort, wo es gefahrlos ist. if (o.bilder !== null) { const leeren = knopf('Bildspeicher leeren', 'btn'); diff --git a/web/app/src/views/einstellungen/wiedergabe.ts b/web/app/src/views/einstellungen/wiedergabe.ts index 8b37ed3..548c5a9 100644 --- a/web/app/src/views/einstellungen/wiedergabe.ts +++ b/web/app/src/views/einstellungen/wiedergabe.ts @@ -1,4 +1,6 @@ -import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api'; +import { + wiedergabeEinstellung, wiedergabeSetzen, wartungseinstellung, wartungSetzen, +} from '../../api'; import { fehlerzeile, meldung, hinweisZeigen } from '../../util'; import { dialog } from '../../dialog'; @@ -47,8 +49,9 @@ export function wiedergabebereich(): HTMLElement { async function laden(): Promise { let stand; + let wartung; try { - stand = await wiedergabeEinstellung(); + [stand, wartung] = await Promise.all([wiedergabeEinstellung(), wartungseinstellung()]); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); @@ -106,6 +109,55 @@ export function wiedergabebereich(): HTMLElement { wert: stand.encoder, setzen: v => wiedergabeSetzen({ encoder: v }), }))); + + /* ---------- Was die Wiedergabe auf der Platte hinterlässt ---------- */ + + // **Hier und nicht unter einem eigenen Punkt.** Der Kratzplatz ist, was + // die Wiedergabe hinterlässt: umgepackte Dateien, Segmente, Untertitel. + // Drei Felder rechtfertigen keine eigene Seite in der Leiste. + liste.appendChild(abschnitt('Kratzplatz')); + + liste.appendChild(zeile( + 'Obergrenze', + wartung.capGb > 0 ? `${wartung.capGb} GB` : 'Unbegrenzt', + 'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. ' + + 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.', + () => zahlFragen('Obergrenze', 'Gigabyte', String(wartung!.capGb), + v => wartungSetzen({ capGb: Number(v) })))); + + liste.appendChild(zeile( + 'Unbenutzt löschen nach', + wartung.unusedDays > 0 ? `${wartung.unusedDays} Tagen` : 'Nie', + 'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen ' + + 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht. ' + + '0 heisst: nur die Obergrenze zählt.', + () => zahlFragen('Unbenutzt löschen nach', 'Tagen', String(wartung!.unusedDays), + v => wartungSetzen({ unusedDays: Number(v) })))); + + liste.appendChild(zeile( + 'Schonfrist', + `${wartung.graceMinutes} Minuten`, + 'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. ' + + 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte ' + + 'eine Datei mitten im Herunterladen weg.', + () => zahlFragen('Schonfrist', 'Minuten', String(wartung!.graceMinutes), + v => wartungSetzen({ graceMinutes: Number(v) })))); + } + + /** Ein Dialog für eine einzelne Zahl. */ + function zahlFragen( + titel: string, beschriftung: string, wert: string, setzen: (wert: string) => Promise, + ): void { + dialog({ + titel, + felder: [{ name: 'wert', beschriftung, wert, art: 'number' }], + bestaetigen: 'Übernehmen', + tun: async lesen => { + await setzen(lesen('wert')); + hinweisZeigen('Übernommen.'); + await laden(); + }, + }); } /** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */