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>
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 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");
|
|
}
|
|
}
|