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. */