Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft

Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 15:55:56 +02:00
co-authored by Claude Opus 5
parent ab8a66d898
commit ac04901ccc
34 changed files with 1583 additions and 255 deletions
@@ -116,3 +116,67 @@ public class FlaroPathsTests
// Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam.
}
}
/// <summary>Das Ende eines Protokolls lesen, ohne die ganze Datei zu holen.</summary>
public class TagebuchEndeTests
{
private string _wurzel = "";
private Tagebuch _tagebuch = null!;
[SetUp]
public void Anlegen()
{
_wurzel = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-log-" + Guid.CreateVersion7().ToString("N"))).FullName;
_tagebuch = new Tagebuch(
new FlaroPaths { Data = _wurzel, Cache = _wurzel, Transcode = _wurzel },
TimeProvider.System);
}
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
}
[Test]
public void Liefert_die_letzten_Zeilen()
{
var heute = DateOnly.FromDateTime(DateTime.Now);
for (var i = 1; i <= 5000; i++) _tagebuch.Schreiben($"Zeile {i}");
var ende = _tagebuch.Ende(heute, 10).TrimEnd('\n').Split('\n');
Assert.That(ende, Has.Length.EqualTo(10));
Assert.That(ende[^1], Does.Contain("Zeile 5000"));
Assert.That(ende[0], Does.Contain("Zeile 4991"));
}
/// <summary>Die Ausgabe beginnt nie mitten in einer Zeile.</summary>
[Test]
public void Faengt_an_einem_Zeilenanfang_an()
{
var heute = DateOnly.FromDateTime(DateTime.Now);
for (var i = 1; i <= 3000; i++) _tagebuch.Schreiben(new string('x', 300) + $" {i}");
var ende = _tagebuch.Ende(heute, 5).Split('\n')[0];
Assert.That(ende, Does.Match(@"^\d{2}:\d{2}:\d{2} "),
"Sonst beginnt die Anzeige mitten im Satz.");
}
/// <summary>Weniger Zeilen als verlangt ist kein Fehler.</summary>
[Test]
public void Eine_kurze_Datei_kommt_ganz()
{
var heute = DateOnly.FromDateTime(DateTime.Now);
_tagebuch.Schreiben("Eine einzige Zeile");
Assert.That(_tagebuch.Ende(heute, 800), Does.Contain("Eine einzige Zeile"));
}
[Test]
public void Ein_Tag_ohne_Protokoll_ist_leer() =>
Assert.That(_tagebuch.Ende(new DateOnly(2000, 1, 1), 10), Is.Empty);
}
+87 -9
View File
@@ -305,31 +305,30 @@ public class ScanTests
/// <summary>Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen.</summary>
///
/// <remarks>
/// <b>Der Weg, den ein vorhandener Bestand nach dem Update geht.</b> Vorher
/// bestimmte der Dateiname die Serie; eine Datei direkt in der Wurzel
/// ergibt das bis heute. Wandert sie in einen Ordner, gehört sie zu diesem
/// — und der alte Eintrag muss verschwinden, statt als leere Serie in der
/// Mediathek zu bleiben.
/// <b>Der Weg, den ein vorhandener Bestand nach dem Update geht.</b> Ohne
/// Ordner benennt der Teil vor der Folgennummer die Serie. Wandert die
/// Datei in einen Ordner, gehört sie zu diesem — und der alte Eintrag muss
/// verschwinden, statt als leere Serie in der Mediathek zu bleiben.
/// </remarks>
[Test]
public async Task Eine_verwaiste_Serie_verschwindet_beim_naechsten_Lauf()
{
Datei("S01E01 Kapitel 1.mkv");
Datei("Tatortreiniger S01E01 Kapitel 1.mkv");
using var server = new FlaroServer();
var (client, bibliothek) = await MitBibliothek(server, "Shows");
await Scannen(client, bibliothek);
var vorher = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner bleibt nur der Dateiname.");
Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner zählt der Titel vor der Folgennummer.");
var alterName = vorher.Items[0].Title;
// **Verschieben, nicht löschen und neu anlegen.** Beim Verschieben
// bleibt der Zeitstempel erhalten — und genau daran erkennt der Scan,
// dass es dieselbe Datei ist. Ein Test, der sie neu anlegt, prüft
// etwas anderes als das, was ein Mensch tut.
var alt = Path.Combine(_bestand, "S01E01 Kapitel 1.mkv");
var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "S01E01 Kapitel 1.mkv");
var alt = Path.Combine(_bestand, "Tatortreiniger S01E01 Kapitel 1.mkv");
var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "Tatortreiniger S01E01 Kapitel 1.mkv");
Directory.CreateDirectory(Path.GetDirectoryName(neuerPfad)!);
File.Move(alt, neuerPfad);
@@ -342,6 +341,85 @@ public class ScanTests
Assert.That(nachher.Items[0].EpisodeCount, Is.EqualTo(1));
}
/// <summary>Aus einem nackten Dateinamen wird keine Serie erfunden.</summary>
///
/// <remarks>
/// <b>Der teuerste Fehler des Projekts.</b> Lag eine Datei direkt in der
/// Wurzel und trug vor der Folgennummer nichts, wurde der ganze Dateiname
/// zum Serientitel — samt Folgentitel. Aus 95 Serien wurden 737, jede mit
/// genau einer Folge, und das bei jedem Lauf aufs Neue. Lieber nichts
/// behaupten als eine Geisterserie anlegen.
/// </remarks>
[Test]
public async Task Ein_nackter_Dateiname_erfindet_keine_Serie()
{
Datei("S01E01.mkv");
Datei("S01E02.mkv");
Datei("S02E01.mkv");
using var server = new FlaroServer();
var (client, bibliothek) = await MitBibliothek(server, "Shows");
await Scannen(client, bibliothek);
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
Assert.That(serien!.Total, Is.EqualTo(0),
"Drei namenlose Dateien ergaben vorher drei Serien.");
}
/// <summary>Lose Dateien derselben Serie landen unter einer Serie.</summary>
///
/// <remarks>
/// Ohne Ordner trägt der Teil vor der Folgennummer den Serientitel. Dass
/// dahinter bei jeder Datei ein anderer Folgentitel steht, darf daran
/// nichts ändern — genau daraus entstanden die Dutzende Einträge
/// „In höchster Not · …", jeder mit einer Folge.
/// </remarks>
[Test]
public async Task Lose_Dateien_mit_Folgentiteln_ergeben_eine_Serie()
{
Datei("In höchster Not - S01E01 - Bergrettung am Grat.mkv");
Datei("In höchster Not - S01E02 - Lawine im Tal.mkv");
Datei("In höchster Not - S01E03 - Sturz in die Klamm.mkv");
using var server = new FlaroServer();
var (client, bibliothek) = await MitBibliothek(server, "Shows");
await Scannen(client, bibliothek);
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
Assert.That(serien!.Total, Is.EqualTo(1));
Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(3));
}
/// <summary>Derselbe Pfad zweimal bringt den ganzen Lauf nicht mehr um.</summary>
///
/// <remarks>
/// Ein doppelt eingehängter Ordner oder eine Verknüpfung zurück in die
/// Mediathek liefert denselben Pfad ein zweites Mal. Vorher entstand dabei
/// eine zweite Zeile mit gleichem <c>(LibraryId, Path)</c>, der
/// Eindeutigkeitsindex brach, und <b>der ganze Scan</b> endete mit
/// „An error occurred while saving the entity changes".
/// </remarks>
[Test]
public async Task Ein_doppelter_Pfad_bricht_den_Scan_nicht_ab()
{
Datei("Pingu/Pingu - S01E01.mkv");
// Eine Verknüpfung auf den Serienordner — derselbe Inhalt, zweiter Weg.
Directory.CreateSymbolicLink(
Path.Combine(_bestand, "Pingu-nochmal"), Path.Combine(_bestand, "Pingu"));
using var server = new FlaroServer();
var (client, bibliothek) = await MitBibliothek(server, "Shows");
await Scannen(client, bibliothek);
var zustand = await client.GetFromJsonAsync<ScanStatus>(
$"/api/v1/libraries/{bibliothek}/scan");
Assert.That(zustand!.State, Is.Not.EqualTo("failed"), zustand.Error ?? "");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
Assert.That(serien!.Total, Is.EqualTo(1), "Zwei Wege, eine Serie.");
}
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
[Test]
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()