Der Kratzplatz räumt sich auf
39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.
**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.
**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.
Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.
„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. Die Zugriffszeit wäre das Naheliegende, nur
führt sie nicht jedes Dateisystem — mit `noatime` steht sie still, und der
Aufräumer hielte alles für uralt.
Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.
**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.
**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: eine .vtt ist
Kilobytes, und eine gültige wird wieder gebraucht — sie nach Platz zu
löschen hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen. Eine leere
bleibt ausdrücklich liegen: sie ist das Ergebnis „diese Spur hat keinen
Text", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.
**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.
Gemessen am laufenden Server mit Attrappen:
vorher packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
nachher 14 MB freigegeben, 4 Dateien
gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
verwaiste .vtt weg, gültige blieben, Stromordner weg
Obergrenze 1 MB, Datei in der Schonfrist -> bleibt
Obergrenze 1 MB, Schonfrist vorbei -> 3072 KB freigegeben
In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.
714 Tests grün, davon 36 neue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1b4784f1db
commit
c4af182d31
@@ -26,8 +26,26 @@ public sealed record SystemInfo(
|
||||
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||
/// </param>
|
||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||
/// <summary>Wie viel auf dem Kratzplatz liegen bleiben darf.</summary>
|
||||
///
|
||||
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
|
||||
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
|
||||
/// <param name="GraceMinutes">Wie lange frisch Gebrauchtes in jedem Fall bleibt.</param>
|
||||
public sealed record MaintenanceSettingsRequest(
|
||||
double? CapGb, int? UnusedDays, int? GraceMinutes);
|
||||
|
||||
public sealed record MaintenanceSettingsResponse(
|
||||
double CapGb, int UnusedDays, int GraceMinutes);
|
||||
|
||||
/// <param name="Teile">
|
||||
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
|
||||
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
|
||||
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
|
||||
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
|
||||
/// </param>
|
||||
public sealed record Speicherort(
|
||||
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
|
||||
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder,
|
||||
IReadOnlyList<Teilbericht>? Teile = null);
|
||||
|
||||
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
|
||||
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
|
||||
|
||||
@@ -25,7 +25,8 @@ public static class DeliveryEndpoints
|
||||
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
||||
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
|
||||
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr,
|
||||
HttpContext zusammenhang) =>
|
||||
{
|
||||
var gefunden = await Laden(db, id);
|
||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||
@@ -47,6 +48,18 @@ public static class DeliveryEndpoints
|
||||
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
|
||||
if (File.Exists(fertig))
|
||||
{
|
||||
// **Festhalten, solange die Antwort läuft.** Ein Download
|
||||
// über Mobilfunk dauert Stunden, und zwischen zwei
|
||||
// Bereichsanfragen kommt lange nichts — ohne diesen Griff
|
||||
// räumte der Aufräumer die Datei unter dem Client weg, und
|
||||
// der finge von vorn an.
|
||||
var griff = packer.Benutzen(fertig);
|
||||
zusammenhang.Response.OnCompleted(() => { griff.Dispose(); return Task.CompletedTask; });
|
||||
|
||||
// Und festhalten, dass sie gebraucht wurde: die Änderungszeit
|
||||
// ist der Benutzungsstempel, nach dem der Aufräumer sortiert.
|
||||
packer.AlsBenutztStempeln(fertig);
|
||||
|
||||
return Results.File(fertig, "video/mp4",
|
||||
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
|
||||
enableRangeProcessing: true);
|
||||
|
||||
@@ -151,6 +151,44 @@ public static class SettingsEndpoints
|
||||
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
|
||||
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
|
||||
|
||||
app.MapGet("/api/v1/settings/maintenance", async (Kratzplatzeinstellungen einstellungen) =>
|
||||
{
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
return Results.Ok(new MaintenanceSettingsResponse(
|
||||
regeln.ObergrenzeBytes / 1024d / 1024 / 1024,
|
||||
(int)regeln.Unbenutzt.TotalDays,
|
||||
(int)regeln.Schonfrist.TotalMinutes));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/settings/maintenance", async (
|
||||
MaintenanceSettingsRequest anfrage, FlaroContext db, Kratzplatzeinstellungen einstellungen) =>
|
||||
{
|
||||
var vorher = await einstellungen.RegelnAsync();
|
||||
|
||||
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
|
||||
// kennt, soll die anderen nicht zurücksetzen.
|
||||
var nachher = Kratzplatzregeln.Geprueft(
|
||||
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
||||
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
|
||||
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
|
||||
|
||||
var zahl = System.Globalization.CultureInfo.InvariantCulture;
|
||||
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
|
||||
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
|
||||
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
|
||||
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
|
||||
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
|
||||
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new MaintenanceSettingsResponse(
|
||||
nachher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
||||
(int)nachher.Unbenutzt.TotalDays,
|
||||
(int)nachher.Schonfrist.TotalMinutes));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
|
||||
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
|
||||
|
||||
@@ -251,11 +251,15 @@ public static class SystemEndpoints
|
||||
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||
// nachsieht — nicht der letzte.
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Kratzplatzpflege pflege) =>
|
||||
{
|
||||
Speicherort Messen(string name, string variable, string pfad) =>
|
||||
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
|
||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
|
||||
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt
|
||||
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
|
||||
// sind — und davon hängt ab, ob es ein Problem ist.
|
||||
name == "Umpacken" ? pflege.Belegung() : null);
|
||||
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
@@ -265,6 +269,17 @@ public static class SystemEndpoints
|
||||
});
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Kratzplatz aufräumen.**
|
||||
//
|
||||
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
|
||||
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
|
||||
// von Gebrauchtem unterscheidet und einen laufenden Download in Ruhe
|
||||
// lässt, ist keine Löschung eines Dings.
|
||||
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
|
||||
Kratzplatzpflege pflege, CancellationToken ct) =>
|
||||
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Bildspeicher leeren.**
|
||||
//
|
||||
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Die Regeln fürs Aufräumen des Kratzplatzes, aus den Einstellungen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
|
||||
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
|
||||
/// Migration.
|
||||
/// </remarks>
|
||||
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<Kratzplatzregeln> 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<string, string> 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;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Was ein Aufräumlauf freigemacht hat.</summary>
|
||||
public sealed record Aufraeumbericht(
|
||||
long Frei, int Dateien, IReadOnlyList<Teilbericht> Teile);
|
||||
|
||||
/// <param name="Name">Der Unterordner — <c>stroeme</c>, <c>packages</c>, <c>untertitel</c>.</param>
|
||||
public sealed record Teilbericht(string Name, long Frei, int Dateien);
|
||||
|
||||
/// <summary>Räumt den Kratzplatz auf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass.</b> 39 Gigabyte in <c>FLARO_TRANSCODE</c>. 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Kein Dienst, kein Takt.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Einer zur Zeit.</b> Wer nicht hineinkommt, geht weiter; zwei Läufe
|
||||
/// nebeneinander löschten einander die Dateien unter den Händen weg.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Kratzplatzpflege(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
PackageCoordinator pakete,
|
||||
Streamkoordinator stroeme,
|
||||
TimeProvider uhr,
|
||||
ILogger<Kratzplatzpflege> log)
|
||||
{
|
||||
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
|
||||
|
||||
/// <summary>Woher der Lauf kam — fürs Protokoll.</summary>
|
||||
public enum Anlass { Start, VorDemUmpacken, NachDemScan, VonHand }
|
||||
|
||||
/// <summary>Räumt auf und sagt, was freigeworden ist.</summary>
|
||||
public async Task<Aufraeumbericht> 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<Teilbericht>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verwaiste Pakete, zu alte Pakete und alles über der Obergrenze.</summary>
|
||||
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<FlaroContext>();
|
||||
var regeln = await raum.ServiceProvider
|
||||
.GetRequiredService<Kratzplatzeinstellungen>().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<Paketakte>();
|
||||
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<Guid>().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);
|
||||
}
|
||||
|
||||
/// <summary>Untertitel zu Dateien, die es nicht mehr gibt oder die sich geändert haben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Keine Obergrenze, kein Alter.</b> Eine <c>.vtt</c> ist Kilobytes,
|
||||
/// und eine gültige wird wieder gebraucht — sie nach Platz zu löschen
|
||||
/// hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen.
|
||||
///
|
||||
/// <b>Eine leere bleibt ausdrücklich liegen.</b> 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.
|
||||
/// </remarks>
|
||||
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<FlaroContext>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Die Belegung je Unterordner — für die Übersicht.</summary>
|
||||
public IReadOnlyList<Teilbericht> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,80 @@ public sealed class PackageCoordinator(
|
||||
/// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und
|
||||
/// liefen gleichzeitig.
|
||||
/// </remarks>
|
||||
private const int Bauart = 2;
|
||||
public const int Bauart = 2;
|
||||
|
||||
/// <summary>Der Ordner, in dem die umgepackten Dateien liegen.</summary>
|
||||
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));
|
||||
|
||||
/// <summary>Wer gerade an einer Datei hängt — die wird nicht weggeräumt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Die Schonfrist allein genügt nicht.</b> 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 <b>Fortsetzung</b> aber nicht — der
|
||||
/// Client bekäme 202 und finge von vorn an. Genau das schliesst die
|
||||
/// Beschreibung dieser Klasse oben aus.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<string, int> _inBenutzung = new();
|
||||
|
||||
public bool IstInBenutzung(string pfad) => _inBenutzung.ContainsKey(pfad);
|
||||
|
||||
/// <summary>Hält eine Datei fest, bis das Ergebnis entsorgt wird.</summary>
|
||||
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<string, int>(pfad, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hält fest, dass diese Datei gerade gebraucht wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Änderungszeit als Benutzungsstempel.</b> Die Zugriffszeit wäre
|
||||
/// das Naheliegende, nur führt sie nicht jedes Dateisystem — mit
|
||||
/// <c>noatime</c> 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.
|
||||
///
|
||||
/// <b>Gedrosselt.</b> Eine Bereichsanfrage je Sekunde ist bei einem Film
|
||||
/// normal; jedes Mal den Inode anzufassen wäre Schreiblast für nichts.
|
||||
/// </remarks>
|
||||
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<Kratzplatzpflege>()
|
||||
.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<FfmpegRemuxer>();
|
||||
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)
|
||||
|
||||
@@ -112,6 +112,9 @@ builder.Services.AddSingleton<PackageCoordinator>();
|
||||
builder.Services.AddSingleton<Streamkoordinator>();
|
||||
builder.Services.AddSingleton<UntertitelLeser>();
|
||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
||||
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
||||
builder.Services.AddScoped<Kratzplatzeinstellungen>();
|
||||
builder.Services.AddSingleton<Kratzplatzpflege>();
|
||||
// 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<PlaybackSessions>();
|
||||
@@ -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<Kratzplatzpflege>()
|
||||
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
|
||||
@@ -160,6 +160,13 @@ public sealed class ScanCoordinator(
|
||||
_ = bereich.ServiceProvider.GetRequiredService<Untertitelvorrat>()
|
||||
.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<Kratzplatzpflege>()
|
||||
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan);
|
||||
|
||||
_zustand[libraryId] = _zustand[libraryId] with
|
||||
{
|
||||
Status = "finished",
|
||||
|
||||
@@ -354,6 +354,82 @@ public sealed class Streamkoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Ordner, in dem die Segmente liegen.</summary>
|
||||
public string Wurzel => Path.Combine(pfade.Transcode, "stroeme");
|
||||
|
||||
/// <summary>Räumt alles weg, was unter der Wurzel liegt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Beim Herunterfahren und beim Hochfahren.</b> 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Räumt Ordner weg, zu denen es keine Sitzung mehr gibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nicht bloss Symmetrie zum Herunterfahren.</b> Scheitert das
|
||||
/// Löschen in <c>Abraeumen</c> an einem <c>IOException</c>, 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Wie viele Bytes unter einem Ordner liegen.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Beim Herunterfahren bleibt nichts laufen und nichts liegen.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace Flaro.Core.Delivery;
|
||||
|
||||
/// <summary>Wann eine umgepackte Datei weggeräumt wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass: 39 Gigabyte.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum Obergrenze und Alter.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ObergrenzeBytes">Wie viel insgesamt liegen bleiben darf; <c>0</c> heisst unbegrenzt.</param>
|
||||
/// <param name="Unbenutzt">Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; <c>Zero</c> heisst nie.</param>
|
||||
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
|
||||
public sealed record Kratzplatzregeln(
|
||||
long ObergrenzeBytes,
|
||||
TimeSpan Unbenutzt,
|
||||
TimeSpan Schonfrist)
|
||||
{
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static Kratzplatzregeln Vorgabe { get; } =
|
||||
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
|
||||
|
||||
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine zu kurze Schonfrist ist gefährlicher als gar keine.</b> 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".
|
||||
/// </remarks>
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>Eine Datei im Kratzplatz, so wie sie dort liegt.</summary>
|
||||
///
|
||||
/// <param name="Benutzt">
|
||||
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
|
||||
/// <b>Und nicht die Zugriffszeit:</b> die führt nicht jedes Dateisystem
|
||||
/// (<c>noatime</c>), die Änderungszeit schon. Jede Auslieferung stempelt sie
|
||||
/// neu; nach dem Bauen steht sie ohnehin richtig.
|
||||
/// </param>
|
||||
public readonly record struct Paketakte(
|
||||
string Pfad,
|
||||
long Groesse,
|
||||
DateTimeOffset Benutzt,
|
||||
Paketname.Teile? Name);
|
||||
|
||||
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
|
||||
public static class Kratzplatzauswahl
|
||||
{
|
||||
/// <param name="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
|
||||
/// <param name="bauart">Wie der Server heute baut.</param>
|
||||
/// <param name="inBenutzung">Ob gerade jemand diese Datei herunterlädt.</param>
|
||||
public static IReadOnlyList<Paketakte> Wegzuwerfen(
|
||||
IReadOnlyList<Paketakte> akten,
|
||||
IReadOnlyDictionary<Guid, long> bekannt,
|
||||
int bauart,
|
||||
Kratzplatzregeln regeln,
|
||||
DateTimeOffset jetzt,
|
||||
Func<string, bool> inBenutzung)
|
||||
{
|
||||
var weg = new List<Paketakte>();
|
||||
var behalten = new List<Paketakte>();
|
||||
|
||||
// **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<Guid, long> bekannt, int bauart) =>
|
||||
a.Name is not { } n
|
||||
|| n.Bauart != bauart
|
||||
|| !bekannt.TryGetValue(n.DateiId, out var ms)
|
||||
|| ms != n.GeaendertMs;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Flaro.Core.Delivery;
|
||||
|
||||
/// <summary>Der Name einer umgepackten Datei — gebaut und wieder gelesen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum der Name alles trägt.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Drei Angaben.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gebaut wird unter <c>.teil</c>.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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}");
|
||||
|
||||
/// <summary>Was in einem Namen steht — oder <c>null</c>, wenn er keiner ist.</summary>
|
||||
public readonly record struct Teile(Guid DateiId, long GeaendertMs, int Bauart);
|
||||
|
||||
/// <summary>Liest einen Dateinamen; der Pfad davor stört nicht.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Was der Server auf dem Kratzplatz wegräumt — und was nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass: 39 Gigabyte.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Hier wird die Verdrahtung geprüft</b>, nicht die Regel — die steht
|
||||
/// ohne Platte und ohne Server in
|
||||
/// <c>Flaro.Core.Tests/KratzplatzregelTests</c>. 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class KratzplatzTests
|
||||
{
|
||||
private string _bestand = "";
|
||||
|
||||
/// <summary>Ein fester Änderungszeitpunkt, damit der Name vorhersagbar ist.</summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>Ein Server mit einem eingelesenen Film und bekanntem Kratzplatz.</summary>
|
||||
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<LibraryResponse>())!;
|
||||
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||
for (var versuch = 0; versuch < 100; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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<List<Speicherort>>("/api/v1/system/storage");
|
||||
var kratz = orte!.First(o => o.Variable == "FLARO_TRANSCODE").Pfad;
|
||||
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
var film = await client.GetFromJsonAsync<MovieResponse>($"/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<Aufraeumbericht?> Aufraeumen(HttpClient client) =>
|
||||
client.PostAsync("/api/v1/system/storage/transcode/cleanup", null)
|
||||
.ContinueWith(a => a.Result.Content.ReadFromJsonAsync<Aufraeumbericht>()).Unwrap();
|
||||
|
||||
/// <summary>Ein Paket, zu dem es keinen Eintrag mehr gibt, fliegt.</summary>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein Paket, das noch gilt, bleibt liegen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die wichtigere Hälfte.</b> Ein Aufräumer, der zu viel wegwirft,
|
||||
/// kostet bei jedem zweiten Abspielen die volle Umpackzeit — und das
|
||||
/// merkt man erst im Betrieb.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Ein Paket zum alten Stand derselben Datei fliegt.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Was eine frühere Fassung des Servers gebaut hat, fliegt.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Ein liegengebliebenes Teilstück fliegt bedingungslos.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Ein verwaister Stromordner ist nach dem Aufräumen weg.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Ein eben beschriebener Stromordner bleibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Eine Untertiteldatei zu einem gelöschten Titel fliegt.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl.</summary>
|
||||
[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<List<Speicherort>>("/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<MaintenanceSettingsResponse>("/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<MaintenanceSettingsResponse>("/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<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
|
||||
|
||||
Assert.That(stand!.GraceMinutes, Is.EqualTo(5),
|
||||
"eine Minute löschte die Datei mitten im Herunterladen weg");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Flaro.Core.Delivery;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Welche umgepackten Dateien weggeräumt werden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Anlass: 39 Gigabyte Kratzplatz.</b> 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>Eine Datei, wie sie im Kratzplatz läge.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Die Mediathek kennt beide Dateien in genau diesem Stand.</summary>
|
||||
private static readonly Dictionary<Guid, long> Bekannt =
|
||||
new() { [EinsId] = 1_000, [ZweiId] = 1_000 };
|
||||
|
||||
private static IReadOnlyList<string> Weg(
|
||||
IReadOnlyList<Paketakte> akten,
|
||||
Kratzplatzregeln? regeln = null,
|
||||
IReadOnlyDictionary<Guid, long>? bekannt = null,
|
||||
Func<string, bool>? inBenutzung = null) =>
|
||||
Kratzplatzauswahl.Wegzuwerfen(
|
||||
akten, bekannt ?? Bekannt, Bauart,
|
||||
regeln ?? Kratzplatzregeln.Vorgabe, Jetzt,
|
||||
inBenutzung ?? (_ => false))
|
||||
.Select(a => a.Pfad).ToList();
|
||||
|
||||
/// <summary>Was nie wieder ausgeliefert wird, geht — auch wenn Platz wäre.</summary>
|
||||
[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<Guid, long>()), 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));
|
||||
}
|
||||
|
||||
/// <summary>Die Schonfrist schützt auch Verwaiste nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Über der Obergrenze geht das am längsten Ungenutzte zuerst.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Unbenutztes geht auch, wenn noch Platz wäre.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Auch mit abgeschalteten Regeln fliegt Verwaistes.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// „Unbegrenzt" heisst „so viel du willst", nicht „sammle auch Müll".
|
||||
/// </remarks>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Name einer umgepackten Datei, in beide Richtungen.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -188,6 +188,38 @@ public class RemuxTests
|
||||
Assert.That(File.Exists(ziel), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Der Umpacker schreibt dorthin, wohin man ihn schickt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Warum das eine eigene Prüfung ist.</b> Der Koordinator lässt jetzt
|
||||
/// unter <c>.teil</c> 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user