Files
ScarriffleandClaude Opus 5 68abd71074 Ein Bild, wo bisher nur eine Zahl stand
Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web
beim Überfahren, in der App beim Ziehen.

**Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453
Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für
Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die
Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch.
Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein
Zweistünder liegt damit bei etwa fünf Sekunden.

**Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2,
tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert
Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe
Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes
dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen
kann, sieht niemand.

**Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.**
Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die
Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App —
und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau
für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken
die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des
nächsten, und hinter dem Ende.

**Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2`
hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere
Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben.

Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung
und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne
Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf.

**Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus,
überliess der Spieler die Pfeile dem Browser — der verschiebt `value`
und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht
verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil
heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden
gewesen, weil `step` auf 0,1 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 17:05:55 +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 jeden Unterordner mit Grösse und Anzahl.</summary>
[Test]
public async Task Die_Belegung_nennt_die_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", "vorschau" }));
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");
}
}