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>
323 lines
12 KiB
C#
323 lines
12 KiB
C#
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");
|
|
}
|
|
}
|