„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>
268 lines
11 KiB
C#
268 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Flaro.Api;
|
|
using Flaro.Api.Endpoints;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Sichern, aufräumen, Bilder wegwerfen — und was dabei herauskommt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Die Sicherung ist das Stück, das im Ernstfall zählt.</b> Eine Kopie,
|
|
/// die sich nicht öffnen lässt, ist schlimmer als keine: man verlässt sich
|
|
/// darauf und merkt es erst, wenn es zu spät ist. Deshalb prüft der Test
|
|
/// nicht, dass eine Datei entstanden ist, sondern dass sie sich als
|
|
/// Datenbank öffnen lässt und die Zeilen enthält.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Warum nicht einfach die Datei kopieren.</b> Die Datenbank läuft im
|
|
/// WAL-Modus: neben <c>flaro.db</c> stehen <c>-wal</c> und <c>-shm</c>, und
|
|
/// wer nur die erste kopiert, bekommt einen Stand von vor Stunden oder
|
|
/// einen halben. <c>VACUUM INTO</c> schreibt eine vollständige Kopie im
|
|
/// laufenden Betrieb — genau das hält dieser Test fest.
|
|
/// </para>
|
|
/// </remarks>
|
|
[TestFixture]
|
|
public sealed class WartungsplanTests
|
|
{
|
|
private static async Task<HttpClient> AlsAdmin(FlaroServer server)
|
|
{
|
|
var admin = await server.SetUpAndSignIn();
|
|
return server.AlsAngemeldeter(admin.Token);
|
|
}
|
|
|
|
private static Task<HttpResponseMessage> Ausfuehren(HttpClient client, string aufgabe) =>
|
|
client.PostAsync($"/api/v1/system/maintenance/{aufgabe}", null);
|
|
|
|
private static async Task<string> Datenordner(HttpClient client)
|
|
{
|
|
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
|
|
return orte!.First(o => o.Variable == "FLARO_DATA").Pfad;
|
|
}
|
|
|
|
/// <summary>Die Sicherung ergibt eine Datenbank, die sich öffnen lässt.</summary>
|
|
[Test]
|
|
public async Task Die_Sicherung_ist_eine_lesbare_Datenbank()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var antwort = await Ausfuehren(client, "sicherung");
|
|
antwort.EnsureSuccessStatusCode();
|
|
|
|
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
|
|
var kopien = Directory.GetFiles(ordner, "flaro-*.db");
|
|
|
|
Assert.That(kopien, Has.Length.EqualTo(1));
|
|
|
|
// Die Probe aufs Exempel: öffnen und zählen.
|
|
await using var verbindung = new SqliteConnection($"Data Source={kopien[0]};Mode=ReadOnly");
|
|
await verbindung.OpenAsync();
|
|
await using var befehl = verbindung.CreateCommand();
|
|
befehl.CommandText = "SELECT COUNT(*) FROM Users";
|
|
|
|
Assert.That(Convert.ToInt32(await befehl.ExecuteScalarAsync()), Is.EqualTo(1),
|
|
"der eingerichtete Administrator muss in der Kopie stehen");
|
|
}
|
|
|
|
/// <summary>Es bleiben so viele Sicherungen liegen, wie eingestellt ist.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Ohne Grenze füllt eine Sicherung alle drei Tage über ein Jahr
|
|
/// hundertzwanzig Kopien derselben Datenbank an — auf demselben Volume,
|
|
/// das sie schützen soll.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Alte_Sicherungen_werden_weggeraeumt()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
|
|
new MaintenanceSettingsRequest(null, null, null, BackupKeep: 2));
|
|
|
|
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
|
|
Directory.CreateDirectory(ordner);
|
|
// Drei ältere Stände, die der Lauf überzählig macht.
|
|
foreach (var tag in new[] { "2020-01-01-0000", "2020-01-02-0000", "2020-01-03-0000" })
|
|
await File.WriteAllTextAsync(Path.Combine(ordner, $"flaro-{tag}.db"), "alt");
|
|
|
|
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
|
|
|
|
Assert.That(Directory.GetFiles(ordner, "flaro-*.db"), Has.Length.EqualTo(2),
|
|
"die neue und die neueste alte");
|
|
}
|
|
|
|
/// <summary>Die Sicherung landet im eingestellten Ordner.</summary>
|
|
[Test]
|
|
public async Task Ein_eigener_Ordner_wird_benutzt()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var eigen = Path.Combine(Path.GetTempPath(), "flaro-sicherung-" + Guid.CreateVersion7().ToString("N"));
|
|
try
|
|
{
|
|
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
|
|
new MaintenanceSettingsRequest(null, null, null, BackupFolder: eigen));
|
|
|
|
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
|
|
|
|
Assert.That(Directory.GetFiles(eigen, "flaro-*.db"), Has.Length.EqualTo(1));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(eigen, recursive: true); } catch (IOException) { /* Kür */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Das Aufräumen der Datenbank läuft durch und meldet etwas.</summary>
|
|
[Test]
|
|
public async Task Die_Datenbank_laesst_sich_optimieren()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var antwort = await Ausfuehren(client, "optimieren");
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
}
|
|
|
|
/// <summary>Ein Bild, auf das nichts zeigt, fliegt — ein benutztes nicht.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Die Schonfrist ist der Grund für die Zeitstempel.</b> Ein Bild
|
|
/// wird abgelegt, bevor der Verweis darauf gespeichert ist; ohne Frist
|
|
/// räumte ein Lauf mitten im Scan genau das weg, was gerade geholt wurde.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Verwaiste_Bilder_fliegen_frische_bleiben()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
|
|
var bilder = Path.Combine(orte!.First(o => o.Variable == "FLARO_CACHE").Pfad, "images", "ab");
|
|
Directory.CreateDirectory(bilder);
|
|
|
|
var alt = Path.Combine(bilder, "abverwaistesbild.img");
|
|
var frisch = Path.Combine(bilder, "abfrischesbild.img");
|
|
await File.WriteAllBytesAsync(alt, new byte[256]);
|
|
await File.WriteAllBytesAsync(frisch, new byte[256]);
|
|
File.SetLastWriteTimeUtc(alt, DateTime.UtcNow.AddDays(-1));
|
|
|
|
(await Ausfuehren(client, "bilder")).EnsureSuccessStatusCode();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(File.Exists(alt), Is.False, "auf dieses Bild zeigt nichts mehr");
|
|
Assert.That(File.Exists(frisch), Is.True,
|
|
"das könnte gerade erst abgelegt und noch nicht verknüpft sein");
|
|
});
|
|
}
|
|
|
|
/// <summary>Der Stand nennt jede Aufgabe mit ihrem Abstand.</summary>
|
|
[Test]
|
|
public async Task Der_Stand_nennt_alle_drei_Aufgaben()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(stand!.Select(s => s.Aufgabe),
|
|
Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" }));
|
|
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3));
|
|
Assert.That(stand!.All(s => s.Zuletzt is null), Is.True, "noch lief keine");
|
|
});
|
|
}
|
|
|
|
/// <summary>Nach einem Lauf steht da, wann er war und was herauskam.</summary>
|
|
[Test]
|
|
public async Task Nach_einem_Lauf_steht_der_Zeitpunkt_fest()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
await Ausfuehren(client, "sicherung");
|
|
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
|
|
var sicherung = stand!.First(s => s.Aufgabe == "Sicherung");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(sicherung.Zuletzt, Is.Not.Null);
|
|
Assert.That(sicherung.Faellig, Is.Not.Null, "und wann die nächste ansteht");
|
|
Assert.That(sicherung.Ergebnis, Does.Contain("MB"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Eine_unbekannte_Aufgabe_wird_abgewiesen()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var antwort = await Ausfuehren(client, "kaffeekochen");
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Wartung_verlangt_Administratorrechte()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var admin = await AlsAdmin(server);
|
|
var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang");
|
|
|
|
Assert.Multiple(async () =>
|
|
{
|
|
Assert.That((await Ausfuehren(gast, "sicherung")).StatusCode,
|
|
Is.EqualTo(HttpStatusCode.Forbidden));
|
|
Assert.That((await gast.GetAsync("/api/v1/system/maintenance")).StatusCode,
|
|
Is.EqualTo(HttpStatusCode.Forbidden));
|
|
});
|
|
}
|
|
|
|
/// <summary>Ein Abstand von null heisst: diese Aufgabe läuft nicht von selbst.</summary>
|
|
[Test]
|
|
public async Task Abgeschaltet_bedeutet_kein_Faelligkeitstermin()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
|
|
new MaintenanceSettingsRequest(null, null, null, BackupDays: 0));
|
|
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
|
|
|
|
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Faellig, Is.Null);
|
|
}
|
|
|
|
/// <summary>Die Einstellungen des Plans kommen zurück, wie sie gesetzt wurden.</summary>
|
|
[Test]
|
|
public async Task Die_Vorgaben_des_Plans_stehen_und_lassen_sich_aendern()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var client = await AlsAdmin(server);
|
|
|
|
var vorher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
|
|
|
|
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
|
|
new MaintenanceSettingsRequest(null, null, null, BackupDays: 1, BackupKeep: 10));
|
|
var nachher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(vorher!.BackupDays, Is.EqualTo(3));
|
|
Assert.That(vorher.OptimizeDays, Is.EqualTo(7));
|
|
Assert.That(nachher!.BackupDays, Is.EqualTo(1));
|
|
Assert.That(nachher.BackupKeep, Is.EqualTo(10));
|
|
Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt");
|
|
Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Transcode-Cache auch");
|
|
});
|
|
}
|
|
}
|