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
+16
View File
@@ -76,6 +76,22 @@ ENV FLARO_DATA=/config \
FLARO_TRANSCODE=/transcode \
DOTNET_gcServer=0
# **Welches Abbild das hier ist.**
#
# Sechs Zeichen, bei jedem Bau neu gewürfelt — auch dann, wenn am Quelltext
# nichts anders ist. Die Version sagt, welcher Stand gemeint ist; diese
# Kennung sagt, welches Abbild wirklich läuft.
#
# Das ist keine Spielerei: zwei Funktionen standen nachweislich im Quelltext
# und waren auf dem Server trotzdem nicht zu sehen. Ob dort ein älteres
# Abbild lief, war nicht feststellbar, und die Fehlersuche lief einen halben
# Tag ins Leere. Seitdem beantwortet ein Blick die erste aller Fragen.
#
# Ganz unten, weil jede Änderung daran alle folgenden Schichten neu baut —
# und das sind hier nur noch die beiden COPY-Zeilen.
ARG FLARO_BUILD=DEV
ENV FLARO_BUILD=${FLARO_BUILD}
WORKDIR /app
COPY --from=build /app .
COPY --from=web /web/dist ./wwwroot
+46
View File
@@ -0,0 +1,46 @@
namespace Flaro.Api;
/// <summary>Welche Version läuft — und welcher Build genau.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum zwei Angaben.</b> Die Version sagt, welcher Stand gemeint ist;
/// sie steht fest und ändert sich nur von Hand. Der Build sagt, welches
/// Abbild tatsächlich läuft, und ändert sich bei <i>jedem</i> Bau — auch
/// dann, wenn am Quelltext nichts anders ist.
/// </para>
/// <para>
/// <b>Und warum das nötig wurde.</b> Es hat einen halben Tag gekostet: zwei
/// Funktionen waren nachweislich im Quelltext und auf dem Server trotzdem
/// nicht zu sehen. Ob dort ein älteres Abbild lief, liess sich nicht
/// feststellen — der Server sagte es nirgends. Jede Fehlersuche lief damit
/// ins Leere, weil die erste Frage unbeantwortbar war: reden wir vom selben
/// Build?
/// </para>
/// <para>
/// <b>Sechs Zeichen, Grossbuchstaben und Ziffern.</b> Kurz genug, um sie
/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal
/// vorzukommen. Ohne <c>I</c>, <c>O</c>, <c>0</c> und <c>1</c>, weil die
/// beim Vorlesen verwechselt werden.
/// </para>
/// </remarks>
public static class Buildkennung
{
/// <summary>Die feste Version. Steigt nur, wenn jemand sie hier ändert.</summary>
public const string Version = "1.0.0";
/// <summary>Die Kennung dieses Abbilds — oder <c>DEV</c> ausserhalb eines Containers.</summary>
///
/// <remarks>
/// Gesetzt wird sie beim Bau des Abbilds über <c>FLARO_BUILD</c>. Fehlt
/// sie, läuft der Server aus dem Quellbaum: dann ist <c>DEV</c> die
/// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet.
/// </remarks>
public static string Build { get; } =
Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert
? wert.Trim()
: "DEV";
/// <summary>„1.0.0 · K7F2QX" — für Protokoll und Anzeige.</summary>
public static string Beides => $"{Version} · {Build}";
}
+13 -1
View File
@@ -5,9 +5,21 @@ namespace Flaro.Api.Endpoints;
// und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo(
string Version, string ServerName, bool NeedsSetup,
string Version, string Build, string ServerName, bool NeedsSetup,
IReadOnlyList<AttributionResponse> Attributions);
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
///
/// <param name="Variable">
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
/// lägen die Dateien woanders als die Datenbank sie vermutet.
/// </param>
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
public sealed record Speicherort(
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
public sealed record ServerNameRequest(string Name);
public sealed record SetupRequest(string UserName, string Password);
@@ -261,6 +261,33 @@ public static class ManagementEndpoints
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Inhalt wegwerfen, die Mediathek behalten.**
//
// Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da,
// den kein Scan mehr geradezieht: die falschen Einträge haben ihre
// Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek
// zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei
// Namen, Pfad und Einstellungen und tippt sie neu.
//
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
// was Flaro über sie weiss.
bibliotheken.MapPost("/{id:guid}/reset", async (Guid id, FlaroContext db) =>
{
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync();
await db.Episodes
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id))
.ExecuteDeleteAsync();
await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
}
}
@@ -28,7 +28,11 @@ public static class SystemEndpoints
{
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
Results.Ok(new SystemInfo(
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
// **Beides aus einer Hand.** Die Assembly-Version war hier
// immer „1.0.0" und sagte deshalb nichts darüber, welches
// Abbild läuft. Der Build daneben tut genau das.
Version: Buildkennung.Version,
Build: Buildkennung.Build,
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
// Server hat, unterscheidet sie an einer Zahl.
@@ -110,7 +114,7 @@ public static class SystemEndpoints
.ToList()))
.RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch) =>
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch, int? tail) =>
{
// **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.**
// Ein Datum, das durch `DateOnly` gegangen ist, kann kein
@@ -122,9 +126,19 @@ public static class SystemEndpoints
}
var datei = tagebuch.DateiFuer(tagesdatum);
return File.Exists(datei)
? Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log")
: Results.NotFound();
if (!File.Exists(datei)) return Results.NotFound();
// **Mit `tail` nur das Ende.** Die Ansicht braucht die letzten
// Zeilen; die ganze Datei dafür zu schicken endete bei 2 MB über
// eine wackelige Verbindung in „Failed to fetch". Ohne `tail`
// kommt weiterhin alles — das ist der Weg zum Herunterladen.
if (tail is > 0 and <= 5000)
{
return Results.Text(
tagebuch.Ende(tagesdatum, tail.Value), "text/plain", System.Text.Encoding.UTF8);
}
return Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
@@ -215,6 +229,97 @@ public static class SystemEndpoints
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
// **Wo was liegt, und wie viel davon.**
//
// Die drei Pfade standen nirgends in der Oberfläche; wer wissen wollte,
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
// nachsieht — nicht der letzte.
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
{
Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
return Results.Ok(new[]
{
Messen("Daten", "FLARO_DATA", pfade.Data),
Messen("Bilder", "FLARO_CACHE", pfade.Cache),
Messen("Umpacken", "FLARO_TRANSCODE", pfade.Transcode),
});
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.**
//
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
// liegen als Datei genauso wieder da, sobald jemand sie neu wählt.
// Darum ohne Umweg über eine Wartungsaktion — es ist ein Cache.
app.MapDelete("/api/v1/system/storage/images", (FlaroPaths pfade) =>
{
var ordner = Path.Combine(pfade.Cache, "images");
if (!Directory.Exists(ordner)) return Results.NoContent();
var befreit = Belegt(ordner);
try
{
Directory.Delete(ordner, recursive: true);
Directory.CreateDirectory(ordner);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
return Results.Problem(Fehlertext.FuerMenschen(fehler));
}
return Results.Ok(new { freed = befreit });
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Auch hier die Kennung.** Wer auf dem NAS nur „curl" hat, soll nicht
// erst die Oberfläche öffnen müssen, um sie zu sehen.
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
version = Buildkennung.Version,
build = Buildkennung.Build,
})).AllowAnonymous();
}
/// <summary>Wie viel ein Ordner belegt — rekursiv, ohne zu werfen.</summary>
///
/// <remarks>
/// Ein unlesbarer Unterordner darf die Auskunft nicht verhindern; dann
/// steht dort eben etwas zu wenig. Eine Zahl, die fast stimmt, ist besser
/// als eine Fehlerseite.
/// </remarks>
private static long Belegt(string pfad)
{
if (!Directory.Exists(pfad)) return 0;
try
{
return new DirectoryInfo(pfad)
.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
})
.Sum(d => d.Length);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
return 0;
}
}
/// <summary>Wie viel auf dem Volume noch frei ist — <c>null</c>, wenn unbekannt.</summary>
private static long? Frei(string pfad)
{
try
{
return new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfad)) ?? "/").AvailableFreeSpace;
}
catch (Exception fehler) when (fehler is ArgumentException or IOException
or UnauthorizedAccessException)
{
return null;
}
}
}
+144
View File
@@ -0,0 +1,144 @@
using System.Text;
using System.Text.RegularExpressions;
using Flaro.Data;
namespace Flaro.Api;
/// <summary>Macht aus einer Ausnahme einen Satz, den ein Mensch lesen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es das braucht.</b> In der Oberfläche stand tagelang „An error
/// occurred while saving the entity changes. See the inner exception for
/// details." — englisch, in einem deutschen Server, und mit dem Verweis auf
/// eine innere Ausnahme, die niemand zu sehen bekam. Das ist keine Meldung,
/// das ist eine Sackgasse: sie nennt weder die Ursache noch einen nächsten
/// Schritt.
/// </para>
/// <para>
/// <b>Zwei Texte, nicht einer.</b> <see cref="FuerMenschen"/> liefert, was in
/// der Oberfläche steht: deutsch, ohne Fehlercode, mit einem Hinweis, was zu
/// tun ist. <see cref="FuersProtokoll"/> liefert alles — Typ, Meldung, jede
/// innere Ausnahme — und landet in der Tagesdatei. Wer den Fehler sucht,
/// findet dort die Einzelheiten; wer den Server nur benutzt, wird nicht
/// damit behelligt.
/// </para>
/// <para>
/// <b>Erkannt wird am Text, nicht am Typ.</b> Ein Verweis auf
/// <c>SqliteException</c> hiesse, den Datenbanktreiber in dieses Projekt zu
/// ziehen, nur um eine Meldung zu formulieren. Die Zeichenkette
/// „UNIQUE constraint failed: Tabelle.Spalte" ist seit Jahren stabil und
/// kostet nichts.
/// </para>
/// </remarks>
public static partial class Fehlertext
{
/// <summary>Was in der Oberfläche steht — deutsch, ohne Fehlercode.</summary>
public static string FuerMenschen(Exception fehler)
{
var regel = VerletzteRegel(fehler);
if (regel is not null) return regel;
// Fehler, die ihren eigenen Text schon für Menschen schreiben, dürfen
// ihn behalten: eine eigene Ausnahme sagt mehr als jede Verallgemeinerung.
if (fehler is BestandVerschwundenException or InvalidOperationException { Source: "Flaro.Data" })
return fehler.Message;
return fehler switch
{
UnauthorizedAccessException =>
"Auf einen Ordner oder eine Datei fehlt das Zugriffsrecht. "
+ "Prüfe die Einhängung und die Benutzerkennung des Containers.",
DirectoryNotFoundException or FileNotFoundException =>
"Ein Ordner oder eine Datei war nicht mehr da. "
+ "Ist das Laufwerk noch eingehängt?",
IOException =>
"Beim Lesen oder Schreiben ist etwas schiefgegangen. "
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
TaskCanceledException or OperationCanceledException =>
"Der Vorgang wurde abgebrochen.",
HttpRequestException =>
"Die Metadatenquelle war nicht erreichbar. "
+ "Beim nächsten Lauf wird es erneut versucht.",
_ =>
"Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll "
+ "von heute, unter „Protokolle“.",
};
}
/// <summary>Alles, was es über den Fehler zu sagen gibt — für die Tagesdatei.</summary>
public static string FuersProtokoll(Exception fehler)
{
var text = new StringBuilder();
var tiefe = 0;
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
{
if (tiefe > 0) text.Append(" ← ");
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
}
return text.ToString();
}
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
///
/// <remarks>
/// <b>Die Tabellen- und Spaltennamen werden übersetzt, nicht
/// durchgereicht.</b> „UNIQUE constraint failed: MediaFiles.LibraryId,
/// MediaFiles.Path" ist für den, der es gebaut hat, eine vollständige
/// Diagnose — und für alle anderen eine Zeichenkette. Was dem Benutzer
/// nützt, ist der Satz dahinter: was kollidiert ist und was er dagegen
/// tun kann.
/// </remarks>
private static string? VerletzteRegel(Exception fehler)
{
string? spalten = null;
for (Exception? e = fehler; e is not null && spalten is null; e = e.InnerException)
{
var treffer = Eindeutigkeit().Match(e.Message);
if (treffer.Success) spalten = treffer.Groups[1].Value;
}
if (spalten is null) return null;
// Normalisiert auf „Tabelle.Spalte, Spalte", damit die Zuordnung unten
// nicht an einem Leerzeichen scheitert.
var schluessel = spalten.Replace(" ", "");
if (schluessel.Contains("MediaFiles.Path"))
{
return "Zwei Einträge beanspruchen denselben Dateipfad. "
+ "Das deutet auf einen Ordner hin, der doppelt eingehängt ist, "
+ "oder auf eine Verknüpfung, die zurück in die Mediathek zeigt. "
+ "Es wurde nichts verändert.";
}
if (schluessel.Contains("Episodes.SeasonNumber") || schluessel.Contains("Episodes.EpisodeNumber"))
{
return "Zwei Dateien beanspruchen dieselbe Folge derselben Staffel. "
+ "Sind es zwei Fassungen, gehören sie an dieselbe Folge — "
+ "dann hilft „Zuordnung korrigieren“. Es wurde nichts verändert.";
}
if (schluessel.Contains("TmdbId"))
{
return "Zwei Titel wurden derselben Vorlage zugeordnet. "
+ "Unter „Doppelte zusammenführen“ lässt sich das auflösen. "
+ "Es wurde nichts verändert.";
}
if (schluessel.Contains("MediaStreams"))
{
return "Eine Datei meldet dieselbe Spur zweimal. "
+ "Beim nächsten Lauf wird sie erneut untersucht.";
}
return "Ein Eintrag gibt es bereits ein zweites Mal. "
+ "Es wurde nichts verändert; die Einzelheiten stehen im Protokoll von heute.";
}
[GeneratedRegex(@"UNIQUE constraint failed:\s*([^'""\r\n]+)", RegexOptions.IgnoreCase)]
private static partial Regex Eindeutigkeit();
}
+3 -1
View File
@@ -118,8 +118,10 @@ public sealed class PackageCoordinator(
catch (Exception fehler)
{
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
// Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz.
_zustand[fileId] = new PackageState(
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message);
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(),
Fehlertext.FuerMenschen(fehler));
}
}
+16
View File
@@ -234,6 +234,22 @@ if (Directory.Exists(oberfläche))
}).AllowAnonymous();
}
// **Start und Ende gehören ins Protokoll.**
//
// Ohne das war nicht zu unterscheiden, ob der Server während eines langen
// Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
{
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
leben.ApplicationStarted.Register(() =>
tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"));
leben.ApplicationStopping.Register(() =>
tagebuch.Schreiben("Server fährt herunter."));
}
app.Run();
/// <summary>Damit die Tests den Server hochfahren können.</summary>
+15 -4
View File
@@ -85,10 +85,17 @@ public sealed class ScanCoordinator(
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
var melder = new Progress<Scanschritt>(schritt =>
{
tagebuch.Schreiben(schritt.Text());
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
// Flackern, in der Tagesdatei ist es die Antwort auf „wieso 737
// Serien?".
if (schritt.Phase == Scanphase.Vermerk) return;
_zustand.AddOrUpdate(libraryId,
_ => new ScanState("running", clock.GetUtcNow(), null, null, null, null, schritt),
(_, alt) => alt with { Schritt = schritt });
tagebuch.Schreiben(schritt.Text());
});
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
@@ -127,17 +134,21 @@ public sealed class ScanCoordinator(
}
catch (Exception fehler)
{
// **Zwei Texte, und zwar absichtlich.** Ins Protokoll geht alles,
// bis zur innersten Ausnahme — dort wird gesucht. In die
// Oberfläche geht ein deutscher Satz ohne Fehlercode; dort wird
// nicht gesucht, dort wird gelesen.
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
using (var bereich = scopes.CreateScope())
{
bereich.ServiceProvider.GetRequiredService<Tagebuch>()
.Schreiben($"Scan gescheitert: {fehler.Message}", Tagebuch.Art.Fehler);
bereich.ServiceProvider.GetRequiredService<Tagebuch>().Schreiben(
$"Scan gescheitert: {Fehlertext.FuersProtokoll(fehler)}", Tagebuch.Art.Fehler);
}
_zustand[libraryId] = _zustand[libraryId] with
{
Status = "failed",
FinishedAt = clock.GetUtcNow(),
Error = fehler.Message,
Error = Fehlertext.FuerMenschen(fehler),
};
}
}
+59
View File
@@ -69,6 +69,65 @@ public sealed class Tagebuch
.ToList();
}
/// <summary>Die letzten Zeilen eines Tages — ohne die ganze Datei zu lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil das Ende interessiert, nicht der Anfang.</b> Wer ins Protokoll
/// sieht, sucht fast immer das Letzte. Die Oberfläche holte bisher die
/// ganze Datei und warf davon 95 % weg — bei 2 MB über eine wackelige
/// Verbindung endete das in „Failed to fetch", und nach einem langen Scan
/// wären es Dutzende Megabyte.
/// </para>
/// <para>
/// Rückwärts in Blöcken gelesen, nicht Zeile für Zeile von vorn: sonst
/// kostet die Ansicht dasselbe wie das Herunterladen, nur ohne den Nutzen.
/// </para>
/// </remarks>
public string Ende(DateOnly tag, int zeilen)
{
var datei = DateiFuer(tag);
if (!File.Exists(datei)) return "";
const int block = 64 * 1024;
try
{
using var strom = new FileStream(
datei, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var gefunden = 0;
var position = strom.Length;
var puffer = new byte[block];
while (position > 0 && gefunden <= zeilen)
{
var lesen = (int)Math.Min(block, position);
position -= lesen;
strom.Position = position;
strom.ReadExactly(puffer, 0, lesen);
for (var i = lesen - 1; i >= 0 && gefunden <= zeilen; i--)
{
if (puffer[i] != (byte)'\n') continue;
gefunden++;
// Eine Zeile zu viel zählen und danach die angebrochene
// erste wegwerfen — sonst beginnt die Ausgabe mitten im
// Satz.
if (gefunden > zeilen) position += i + 1;
}
}
strom.Position = position;
using var leser = new StreamReader(strom, Encoding.UTF8);
return leser.ReadToEnd();
}
catch (IOException)
{
return "";
}
}
/// <summary>Hängt eine Zeile an das Protokoll des heutigen Tages.</summary>
///
/// <remarks>
+27 -3
View File
@@ -5,7 +5,13 @@ namespace Flaro.Core.Library;
public sealed record MovieName(string Title, int? Year);
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle);
/// <param name="AusOrdner">
/// <b>Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen.</b> Steht
/// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737
/// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte.
/// </param>
public sealed record EpisodeName(
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
///
@@ -236,11 +242,29 @@ public static partial class FileNames
? SerieAusOrdner(dateiname, wurzel)
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
// **Der Rohname ist keine Serie.**
//
// Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der
// teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel
// und trägt vor der Folgennummer nichts Brauchbares, wurde der
// komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95
// Serien wurden 737, jede mit genau einer Folge, und jede frisch bei
// jedem Lauf.
//
// Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der
// Folgennummer weiss es — oder niemand weiss es, und dann wird nichts
// erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und
// erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer
// Folge ist schlechter als ein ehrliches „weiss ich nicht".
var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null);
if (serientitel is null) return null;
return new EpisodeName(
SeriesTitle: ausOrdner ?? (serie.Length > 0 ? serie : Rohname(dateiname)),
SeriesTitle: serientitel,
Season: staffel,
Episode: folge,
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null,
AusOrdner: ausOrdner is not null);
}
/// <summary>Der Serienname aus dem Pfad.</summary>
+44 -3
View File
@@ -46,11 +46,44 @@ public static class LibraryWalker
return new WalkResult(gefunden, probleme);
}
Absteigen(wurzel, 0, gefunden, probleme);
// **Jedes echte Verzeichnis genau einmal.** Siehe <see cref="Echtpfad"/>.
var besucht = new HashSet<string>(StringComparer.Ordinal) { Echtpfad(wurzel) };
Absteigen(wurzel, 0, gefunden, probleme, besucht);
return new WalkResult(gefunden, probleme);
}
private static void Absteigen(string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme)
/// <summary>Wohin ein Verzeichnis wirklich zeigt.</summary>
///
/// <remarks>
/// <b>Ohne das wird aus einem Ordner zwei.</b> Ein Verweis — ein Symlink,
/// ein zweiter Bind Mount derselben Freigabe — macht denselben Inhalt unter
/// einem zweiten Namen erreichbar. Der Scan lief beide Wege ab und legte
/// „Pingu" und „Pingu-nochmal" als zwei Serien an; dieselbe Datei bekam
/// zwei Pfade und damit zwei Zeilen, was am Eindeutigkeitsindex den ganzen
/// Speichervorgang zerriss.
///
/// Lässt sich das Ziel nicht auflösen, gilt der Pfad selbst. Lieber einmal
/// zu viel absteigen als wegen eines Rechtefehlers einen echten Ordner
/// auslassen.
/// </remarks>
private static string Echtpfad(string ordner)
{
try
{
var ziel = Directory.ResolveLinkTarget(ordner, returnFinalTarget: true);
return Path.GetFullPath(ziel?.FullName ?? ordner).TrimEnd(Path.DirectorySeparatorChar);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
return Path.GetFullPath(ordner).TrimEnd(Path.DirectorySeparatorChar);
}
}
private static void Absteigen(
string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme,
HashSet<string> besucht)
{
if (tiefe > MaximaleTiefe)
{
@@ -87,7 +120,15 @@ public static class LibraryWalker
foreach (var unter in unterordner)
{
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
Absteigen(unter, tiefe + 1, gefunden, probleme);
if (!besucht.Add(Echtpfad(unter)))
{
probleme.Add(new WalkProblem(
unter, "Zeigt auf einen Ordner, der in diesem Lauf schon erfasst wurde."));
continue;
}
Absteigen(unter, tiefe + 1, gefunden, probleme, besucht);
}
}
}
@@ -20,6 +20,16 @@ public enum Scanphase
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
Aufraeumen,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
///
/// <remarks>
/// <b>Warum getrennt.</b> „Serie ‚Pingu' neu angelegt, aus dem Ordner" ist
/// beim Suchen eines Fehlers Gold wert und in einer Zeile, die im
/// Sekundentakt wechselt, nur Flackern. Die Oberfläche überspringt diese
/// Schritte, die Tagesdatei nimmt sie auf.
/// </remarks>
Vermerk,
}
/// <summary>Ein Zwischenstand: was gerade passiert, und wie weit.</summary>
@@ -60,6 +70,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
Scanphase.Vermerk => Was,
_ => Was,
};
}
+46 -1
View File
@@ -122,9 +122,28 @@ public sealed class LibraryScanner(
var durch = 0;
// **Was dieser Lauf schon in der Hand hatte.**
//
// Ein Pfad darf im selben Lauf nur einmal vorkommen. Kommt er zweimal
// — ein Ordner, der doppelt eingehängt ist, oder eine Verknüpfung, die
// in die Mediathek zurückzeigt —, entstand bisher eine zweite Zeile
// mit demselben `(LibraryId, Path)`. Der Eindeutigkeitsindex liess
// daraufhin den **ganzen** Speichervorgang scheitern: der Scan brach
// ab, die Zähler blieben stehen, und in der Oberfläche stand eine
// englische Meldung über „entity changes".
var gesehen = new HashSet<string>(StringComparer.Ordinal);
foreach (var datei in ergebnis.Files)
{
ct.ThrowIfCancellationRequested();
if (!gesehen.Add(datei.Path))
{
log.LogInformation(
"Scan: {Pfad} kam ein zweites Mal vorbei und wurde übersprungen.", datei.Path);
continue;
}
fortschritt?.Report(new Scanschritt(
Scanphase.Pruefen, datei.Path, ++durch, ergebnis.Files.Count));
@@ -147,10 +166,27 @@ public sealed class LibraryScanner(
// einer Serie, zu der die Datei nicht gehört, und dort
// fände sie niemand je wieder.
unerkannt++;
log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path);
log.LogInformation("Scan: {Pfad} liess sich keiner Serie zuordnen.", datei.Path);
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"Nicht zugeordnet: {datei.Path} — weder ein Ordner noch der "
+ "Dateiname nennen eine Serie."));
continue;
}
var vorher = serien.Count;
var serie = SerieFinden(serien, libraryId, name.SeriesTitle);
// **Jede neue Serie wird begründet.** Ohne diese Zeile liess
// sich nicht feststellen, welcher der beiden Wege — Ordner oder
// Dateiname — die Geisterserien angelegt hatte, und jede
// Fehlersuche blieb Vermutung.
if (serien.Count > vorher)
{
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"Serie „{serie.Title}“ neu angelegt — "
+ (name.AusOrdner ? "aus dem Ordner" : "aus dem Dateinamen")
+ $", wegen {datei.Path}"));
}
folgeId = FolgeFinden(folgen, serie.Id, name).Id;
}
@@ -189,6 +225,11 @@ public sealed class LibraryScanner(
bekannteDateien.Remove(umgezogen.Path);
umgezogen.Path = datei.Path;
// **Unter dem neuen Pfad wieder eintragen.** Der alte Schlüssel
// ist weg, der neue fehlte — und damit war der Eintrag für den
// Rest des Laufs unauffindbar. Trifft derselbe Pfad später noch
// einmal ein, entstünde eine zweite Zeile dafür.
bekannteDateien[datei.Path] = umgezogen;
umgezogen.MovieId = filmId;
umgezogen.EpisodeId = folgeId;
umgezogen.SeenAt = laufZeit;
@@ -215,6 +256,10 @@ public sealed class LibraryScanner(
VersionLabel = FileNames.Fassung(datei.Path),
};
db.MediaFiles.Add(eintrag);
// **Auch Neuzugänge gehören in die Liste.** Sie standen dort
// nie — ein Pfad, der im selben Lauf ein zweites Mal auftauchte,
// wurde deshalb ein zweites Mal angelegt.
bekannteDateien[datei.Path] = eintrag;
neu++;
mussUntersucht = true;
}
@@ -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()