Files
Flaro/server/tests/Flaro.Api.Tests/TranscodeTests.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

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 Transcode-Cache 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/TranscoderegelTests</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 TranscodeTests
{
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 Transcode-Cache.</summary>
private async Task<(HttpClient Client, string Ordner, 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 transcode = orte!.First(o => o.Variable == "FLARO_TRANSCODE");
Assert.Multiple(() =>
{
Assert.That(transcode.Teile, Is.Not.Null);
Assert.That(transcode.Teile!.Select(t => t.Name),
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" }));
var pakete = transcode.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 Transcode-Cache 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");
}
}