diff --git a/Tools/image-veroeffentlichen.sh b/Tools/image-veroeffentlichen.sh index 01f6646..7ab1fe3 100755 --- a/Tools/image-veroeffentlichen.sh +++ b/Tools/image-veroeffentlichen.sh @@ -74,6 +74,17 @@ registry_digest() { MARKEN=(-t "$BILD:latest") [ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG") +# **Eine Kennung je Bau.** +# +# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet +# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie +# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von +# eben?" in zwei Sekunden beantwortet statt in einem halben Tag. +# +# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt. +BUILD=$(LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' < /dev/urandom | head -c 6) +echo "Build-Kennung dieses Laufs: $BUILD" + # **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein # Manifest mit zwei Architekturen bauen; `docker buildx create` schon. docker buildx inspect flaro-bauer >/dev/null 2>&1 \ @@ -88,6 +99,7 @@ if ! docker buildx build \ --builder flaro-bauer \ --platform "$PLATTFORMEN" \ -f server/Dockerfile \ + --build-arg "FLARO_BUILD=$BUILD" \ "${MARKEN[@]}" \ --push . ; then echo @@ -123,5 +135,9 @@ echo echo "Fertig. In der Registry liegt jetzt:" echo " $nachher" echo +echo "Build-Kennung: $BUILD" +echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen." +echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild." +echo echo "Auf dem NAS:" echo " image: $BILD:${FASSUNG:-latest}" diff --git a/server/Dockerfile b/server/Dockerfile index a4b32be..467f5d9 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 diff --git a/server/src/Flaro.Api/Buildkennung.cs b/server/src/Flaro.Api/Buildkennung.cs new file mode 100644 index 0000000..423e0dd --- /dev/null +++ b/server/src/Flaro.Api/Buildkennung.cs @@ -0,0 +1,46 @@ +namespace Flaro.Api; + +/// Welche Version läuft — und welcher Build genau. +/// +/// +/// +/// Warum zwei Angaben. 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 jedem Bau — auch +/// dann, wenn am Quelltext nichts anders ist. +/// +/// +/// Und warum das nötig wurde. 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? +/// +/// +/// Sechs Zeichen, Grossbuchstaben und Ziffern. Kurz genug, um sie +/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal +/// vorzukommen. Ohne I, O, 0 und 1, weil die +/// beim Vorlesen verwechselt werden. +/// +/// +public static class Buildkennung +{ + /// Die feste Version. Steigt nur, wenn jemand sie hier ändert. + public const string Version = "1.0.0"; + + /// Die Kennung dieses Abbilds — oder DEV ausserhalb eines Containers. + /// + /// + /// Gesetzt wird sie beim Bau des Abbilds über FLARO_BUILD. Fehlt + /// sie, läuft der Server aus dem Quellbaum: dann ist DEV die + /// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet. + /// + public static string Build { get; } = + Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert + ? wert.Trim() + : "DEV"; + + /// „1.0.0 · K7F2QX" — für Protokoll und Anzeige. + public static string Beides => $"{Version} · {Build}"; +} diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index f893f92..b205293 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -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 Attributions); +/// Ein Ablageort des Servers, mitsamt Belegung. +/// +/// +/// Die Umgebungsvariable, über die er gesetzt wird. Sie wird mit +/// angezeigt, 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. +/// +/// Wie viel davon auf den Bildspeicher entfällt; sonst null. +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); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index d038edc..860fa03 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -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); } } diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 7b19b30..a81f80f 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -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(); + } + + /// Wie viel ein Ordner belegt — rekursiv, ohne zu werfen. + /// + /// + /// 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. + /// + 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; + } + } + + /// Wie viel auf dem Volume noch frei ist — null, wenn unbekannt. + 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; + } } } diff --git a/server/src/Flaro.Api/Fehlertext.cs b/server/src/Flaro.Api/Fehlertext.cs new file mode 100644 index 0000000..cc308f3 --- /dev/null +++ b/server/src/Flaro.Api/Fehlertext.cs @@ -0,0 +1,144 @@ +using System.Text; +using System.Text.RegularExpressions; +using Flaro.Data; + +namespace Flaro.Api; + +/// Macht aus einer Ausnahme einen Satz, den ein Mensch lesen kann. +/// +/// +/// +/// Warum es das braucht. 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. +/// +/// +/// Zwei Texte, nicht einer. liefert, was in +/// der Oberfläche steht: deutsch, ohne Fehlercode, mit einem Hinweis, was zu +/// tun ist. 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. +/// +/// +/// Erkannt wird am Text, nicht am Typ. Ein Verweis auf +/// SqliteException 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. +/// +/// +public static partial class Fehlertext +{ + /// Was in der Oberfläche steht — deutsch, ohne Fehlercode. + 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“.", + }; + } + + /// Alles, was es über den Fehler zu sagen gibt — für die Tagesdatei. + 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(); + } + + /// Welche Eindeutigkeitsregel gebrochen wurde — in Worten. + /// + /// + /// Die Tabellen- und Spaltennamen werden übersetzt, nicht + /// durchgereicht. „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. + /// + 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(); +} diff --git a/server/src/Flaro.Api/PackageCoordinator.cs b/server/src/Flaro.Api/PackageCoordinator.cs index 6a53636..ce91445 100644 --- a/server/src/Flaro.Api/PackageCoordinator.cs +++ b/server/src/Flaro.Api/PackageCoordinator.cs @@ -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)); } } diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index ae13e0c..7befc0e 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -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(); + var leben = app.Services.GetRequiredService(); + + leben.ApplicationStarted.Register(() => + tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}")); + leben.ApplicationStopping.Register(() => + tagebuch.Schreiben("Server fährt herunter.")); +} + app.Run(); /// Damit die Tests den Server hochfahren können. diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index cab5b90..74972b6 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -85,10 +85,17 @@ public sealed class ScanCoordinator( var tagebuch = bereich.ServiceProvider.GetRequiredService(); var melder = new Progress(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(); @@ -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() - .Schreiben($"Scan gescheitert: {fehler.Message}", Tagebuch.Art.Fehler); + bereich.ServiceProvider.GetRequiredService().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), }; } } diff --git a/server/src/Flaro.Api/Tagebuch.cs b/server/src/Flaro.Api/Tagebuch.cs index 4460934..18800e2 100644 --- a/server/src/Flaro.Api/Tagebuch.cs +++ b/server/src/Flaro.Api/Tagebuch.cs @@ -69,6 +69,65 @@ public sealed class Tagebuch .ToList(); } + /// Die letzten Zeilen eines Tages — ohne die ganze Datei zu lesen. + /// + /// + /// + /// Weil das Ende interessiert, nicht der Anfang. 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. + /// + /// + /// 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. + /// + /// + 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 ""; + } + } + /// Hängt eine Zeile an das Protokoll des heutigen Tages. /// /// diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index 3bb0ffb..207a80c 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -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); +/// +/// Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen. 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. +/// +public sealed record EpisodeName( + string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false); /// Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge. /// @@ -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); } /// Der Serienname aus dem Pfad. diff --git a/server/src/Flaro.Core/Library/LibraryWalker.cs b/server/src/Flaro.Core/Library/LibraryWalker.cs index 22a7369..1ce06c1 100644 --- a/server/src/Flaro.Core/Library/LibraryWalker.cs +++ b/server/src/Flaro.Core/Library/LibraryWalker.cs @@ -46,11 +46,44 @@ public static class LibraryWalker return new WalkResult(gefunden, probleme); } - Absteigen(wurzel, 0, gefunden, probleme); + // **Jedes echte Verzeichnis genau einmal.** Siehe . + var besucht = new HashSet(StringComparer.Ordinal) { Echtpfad(wurzel) }; + + Absteigen(wurzel, 0, gefunden, probleme, besucht); return new WalkResult(gefunden, probleme); } - private static void Absteigen(string ordner, int tiefe, List gefunden, List probleme) + /// Wohin ein Verzeichnis wirklich zeigt. + /// + /// + /// Ohne das wird aus einem Ordner zwei. 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. + /// + 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 gefunden, List probleme, + HashSet 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); } } } diff --git a/server/src/Flaro.Core/Library/Scanschritt.cs b/server/src/Flaro.Core/Library/Scanschritt.cs index 33be573..ad1845e 100644 --- a/server/src/Flaro.Core/Library/Scanschritt.cs +++ b/server/src/Flaro.Core/Library/Scanschritt.cs @@ -20,6 +20,16 @@ public enum Scanphase /// Verwaistes wird entfernt, Doppeltes zusammengelegt. Aufraeumen, + + /// Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile. + /// + /// + /// Warum getrennt. „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. + /// + Vermerk, } /// Ein Zwischenstand: was gerade passiert, und wie weit. @@ -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, }; } diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index f485ada..137797f 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -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(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; } diff --git a/server/tests/Flaro.Api.Tests/FlaroPathsTests.cs b/server/tests/Flaro.Api.Tests/FlaroPathsTests.cs index d0d820d..3532f30 100644 --- a/server/tests/Flaro.Api.Tests/FlaroPathsTests.cs +++ b/server/tests/Flaro.Api.Tests/FlaroPathsTests.cs @@ -116,3 +116,67 @@ public class FlaroPathsTests // Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam. } } + +/// Das Ende eines Protokolls lesen, ohne die ganze Datei zu holen. +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")); + } + + /// Die Ausgabe beginnt nie mitten in einer Zeile. + [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."); + } + + /// Weniger Zeilen als verlangt ist kein Fehler. + [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); +} diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index 5c3efbf..2a096bd 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -305,31 +305,30 @@ public class ScanTests /// Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen. /// /// - /// Der Weg, den ein vorhandener Bestand nach dem Update geht. 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. + /// Der Weg, den ein vorhandener Bestand nach dem Update geht. 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. /// [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>("/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)); } + /// Aus einem nackten Dateinamen wird keine Serie erfunden. + /// + /// + /// Der teuerste Fehler des Projekts. 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. + /// + [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>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(0), + "Drei namenlose Dateien ergaben vorher drei Serien."); + } + + /// Lose Dateien derselben Serie landen unter einer Serie. + /// + /// + /// 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. + /// + [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>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(1)); + Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(3)); + } + + /// Derselbe Pfad zweimal bringt den ganzen Lauf nicht mehr um. + /// + /// + /// 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 (LibraryId, Path), der + /// Eindeutigkeitsindex brach, und der ganze Scan endete mit + /// „An error occurred while saving the entity changes". + /// + [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( + $"/api/v1/libraries/{bibliothek}/scan"); + Assert.That(zustand!.State, Is.Not.EqualTo("failed"), zustand.Error ?? ""); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(1), "Zwei Wege, eine Serie."); + } + /// 4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme. [Test] public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag() diff --git a/web/app/src/api.ts b/web/app/src/api.ts index ffc3a1d..1adcfc1 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -113,7 +113,10 @@ export type Vorschlag = { }; export type Hinweis = { text: string; logoUrl: string; url: string }; export type Systemauskunft = { - version: string; serverName: string | null; + version: string; + /** Die Kennung dieses Abbilds — bei jedem Bau neu, „DEV" ausserhalb eines Containers. */ + build: string; + serverName: string | null; needsSetup: boolean; attributions: Hinweis[]; }; @@ -169,6 +172,12 @@ export const meineSprache = (): string | null => sitzung.wer?.language ?? null; /* ---------- Anfragen ---------- */ export class ApiFehler extends Error { + // **Der Name wird gesetzt, nicht geerbt.** Nach dem Bündeln heisst die + // Klasse anders, und `instanceof` über Modulgrenzen ist nicht verlässlich; + // `name` überlebt beides. Daran erkennt `meldung()`, dass der Text vom + // Server stammt und bereits deutsch ist. + override readonly name = 'ApiFehler'; + constructor(public status: number, nachricht: string) { super(nachricht); } } @@ -182,7 +191,24 @@ async function anfrage(pfad: string, opts: RequestInit = {}): Promise { kopf.set('Content-Type', 'application/json'); } - const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); + // **Einmal nachfassen, bevor gemeldet wird.** + // + // Während eines Scans über tausende Dateien kam es vor, dass eine Anfrage + // ins Leere lief und der Bilddialog mit „Failed to fetch" stehenblieb — + // beim zweiten Versuch von Hand ging es sofort. Ein einzelner Aussetzer ist + // kein Fehler, den der Benutzer sehen muss. + // + // Nur bei einem Netzfehler und nur bei lesenden Anfragen: ein zweites POST + // könnte dasselbe zweimal anlegen, und das wäre schlimmer als die Meldung. + const lesend = !opts.method || opts.method.toUpperCase() === 'GET'; + let antwort: Response; + try { + antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); + } catch (aussetzer) { + if (!lesend || !(aussetzer instanceof TypeError)) throw aussetzer; + await new Promise(r => setTimeout(r, 400)); + antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); + } // **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.** // @@ -580,17 +606,42 @@ export const zurueckziehen = (ids: string[]) => export const reihenfolgeSetzen = (ids: string[]) => anfrage('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) }); +/** Wirft den erfassten Inhalt einer Mediathek weg — die Dateien bleiben liegen. + * + * **Wofür.** Steht nach einer falschen Zuordnungsregel ein Bestand da, den kein + * Scan mehr geradezieht, ist das der kurze Weg: die Einträge weg, einmal neu + * scannen. Mediathek, Pfad und Einstellungen bleiben. + */ +export const mediathekZuruecksetzen = (bibliothek: string) => + anfrage(`/libraries/${bibliothek}/reset`, { method: 'POST' }); + export const pruefungSetzen = (bibliothek: string, an: boolean) => anfrage(`/libraries/${bibliothek}/review`, { method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) }); +export type Speicherort = { + name: string; variable: string; pfad: string; + belegt: number; frei: number | null; bilder: number | null; +}; + +/** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */ +export const speicherorte = () => anfrage('/system/storage'); + +/** Leert den Bildspeicher. Ungefährlich — Bilder werden bei Bedarf neu geholt. */ +export const bildspeicherLeeren = () => + anfrage<{ freed: number }>('/system/storage/images', { method: 'DELETE' }); + export type Protokolltag = { date: string; bytes: number }; /** Welche Tage protokolliert sind, neueste zuerst. */ export const protokolltage = () => anfrage('/system/logs'); -/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */ -export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`; +/** Die Adresse eines Protokolltags. + * + * Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne + * kommt alles, und das ist der Weg zum Herunterladen. */ +export const protokollAdresse = (tag: string, zeilen?: number) => + `/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : ''); export type VermissteDatei = { fileId: string; path: string; versionLabel: string | null; diff --git a/web/app/src/auswahl.ts b/web/app/src/auswahl.ts new file mode 100644 index 0000000..93a503b --- /dev/null +++ b/web/app/src/auswahl.ts @@ -0,0 +1,186 @@ +import { icons } from './icons'; + +export type Option = { wert: string; text: string }; + +/** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche. + * + * **Warum nicht `` bringt viel mit, + * das man leicht vergisst: Tastaturbedienung, Rollen für Hilfsmittel, das + * Schliessen beim Klick daneben. Steht alles unten — ohne das wäre der + * Tausch eine Verschlechterung, nur eine hübschere. + */ +export type Auswahlfeld = { + /** Was in die Seite gehängt wird. */ + element: HTMLButtonElement; + /** Der gewählte Wert — wie bei `select.value`. */ + wert: string; + /** Wird bei jeder Änderung durch den Benutzer gerufen, nicht beim Setzen. */ + beiWechsel: (fn: () => void) => void; + /** Tauscht die Liste aus — für Werte, die erst nachgeladen werden. */ + setzen: (optionen: Option[]) => void; +}; + +export function auswahl(optionen: Option[], vorgabe = ''): Auswahlfeld { + let liste = [...optionen]; + let wert = vorgabe; + let melden: (() => void) | null = null; + + const knopf = document.createElement('button'); + knopf.type = 'button'; + knopf.className = 'auswahl'; + knopf.setAttribute('aria-haspopup', 'listbox'); + knopf.setAttribute('aria-expanded', 'false'); + + const beschriftung = document.createElement('span'); + const pfeil = document.createElement('span'); + pfeil.className = 'auswahl-pfeil icon'; + pfeil.innerHTML = icons.runter; + knopf.append(beschriftung, pfeil); + + const beschriften = () => { + beschriftung.textContent = liste.find(o => o.wert === wert)?.text ?? liste[0]?.text ?? ''; + }; + beschriften(); + + let offen: HTMLElement | null = null; + + function schliessen(): void { + offen?.remove(); + offen = null; + knopf.setAttribute('aria-expanded', 'false'); + } + + function waehlen(neu: string): void { + const geaendert = neu !== wert; + wert = neu; + beschriften(); + schliessen(); + knopf.focus(); + if (geaendert) melden?.(); + } + + function oeffnen(): void { + if (offen) { schliessen(); return; } + + const box = document.createElement('div'); + box.className = 'auswahl-liste'; + box.setAttribute('role', 'listbox'); + + liste.forEach(o => { + const e = document.createElement('button'); + e.type = 'button'; + e.className = 'auswahl-punkt' + (o.wert === wert ? ' gewaehlt' : ''); + e.setAttribute('role', 'option'); + e.setAttribute('aria-selected', String(o.wert === wert)); + e.textContent = o.text; + e.onclick = () => waehlen(o.wert); + box.appendChild(e); + }); + + // **Die Tasten müssen auch in der Liste wirken.** Nach dem Öffnen sitzt + // der Fokus auf einem Eintrag, nicht mehr auf dem Knopf — Escape lief + // dort ins Leere, und die Liste blieb offen stehen. + box.onkeydown = e => { + if (e.key === 'Escape') { e.preventDefault(); schliessen(); knopf.focus(); return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + bewegen(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Home' || e.key === 'End') { + e.preventDefault(); + const punkte = [...box.querySelectorAll('.auswahl-punkt')]; + (e.key === 'Home' ? punkte[0] : punkte[punkte.length - 1])?.focus(); + return; + } + // Tab verlässt die Liste — dann hat sie dort nichts mehr zu suchen. + if (e.key === 'Tab') schliessen(); + }; + + document.body.appendChild(box); + offen = box; + knopf.setAttribute('aria-expanded', 'true'); + + // **Erst anhängen, dann messen.** Vorher hat die Liste keine Grösse, und + // die Prüfung auf den Bildschirmrand liefe gegen null. Dasselbe Vorgehen + // wie im Kontextmenü, und aus demselben Grund. + const ab = knopf.getBoundingClientRect(); + const eigen = box.getBoundingClientRect(); + box.style.minWidth = `${ab.width}px`; + box.style.left = `${Math.max(8, Math.min(ab.left, window.innerWidth - eigen.width - 8))}px`; + box.style.top = ab.bottom + eigen.height + 8 > window.innerHeight + ? `${Math.max(8, ab.top - eigen.height - 4)}px` + : `${ab.bottom + 4}px`; + + box.querySelector('.gewaehlt, .auswahl-punkt')?.focus(); + } + + knopf.onclick = e => { e.stopPropagation(); oeffnen(); }; + + // **Die Tastatur, die ein `select` mitbrachte.** Hoch und runter wechseln + // auch ohne geöffnete Liste — genau so verhält sich das Original, und wer + // es gewohnt ist, merkt den Tausch sonst sofort. + knopf.onkeydown = e => { + const stelle = liste.findIndex(o => o.wert === wert); + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (offen) { bewegen(e.key === 'ArrowDown' ? 1 : -1); return; } + const ziel = liste[Math.min(liste.length - 1, Math.max(0, stelle + (e.key === 'ArrowDown' ? 1 : -1)))]; + if (ziel) waehlen(ziel.wert); + return; + } + + if (e.key === 'Home' || e.key === 'End') { + e.preventDefault(); + const ziel = e.key === 'Home' ? liste[0] : liste[liste.length - 1]; + if (ziel) waehlen(ziel.wert); + return; + } + + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(); return; } + if (e.key === 'Escape') { schliessen(); return; } + + // Anfangsbuchstabe — springt zum nächsten passenden Eintrag. + if (e.key.length === 1 && /\S/.test(e.key)) { + const zeichen = e.key.toLowerCase(); + const ab = stelle + 1; + const treffer = [...liste.slice(ab), ...liste.slice(0, ab)] + .find(o => o.text.toLowerCase().startsWith(zeichen)); + if (treffer) waehlen(treffer.wert); + } + }; + + /** Pfeiltasten in der offenen Liste. */ + function bewegen(richtung: 1 | -1): void { + if (!offen) return; + const punkte = [...offen.querySelectorAll('.auswahl-punkt')]; + const jetzt = punkte.indexOf(document.activeElement as HTMLElement); + const ziel = punkte[Math.min(punkte.length - 1, Math.max(0, jetzt + richtung))]; + ziel?.focus(); + } + + // Ein Klick daneben, Escape oder ein Bildlauf schliessen — wie beim + // Kontextmenü. Je Feld registriert und nicht global: es gibt wenige davon, + // und beim Entfernen des Feldes gehen sie mit. + document.addEventListener('click', e => { + if (offen && !offen.contains(e.target as Node) && e.target !== knopf) schliessen(); + }); + window.addEventListener('scroll', () => schliessen(), { passive: true, capture: true }); + + return { + element: knopf, + get wert() { return wert; }, + set wert(neu: string) { wert = neu; beschriften(); }, + beiWechsel: fn => { melden = fn; }, + setzen: neue => { liste = [...neue]; beschriften(); }, + }; +} diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts index eaf439d..7d6f49b 100644 --- a/web/app/src/bilder.ts +++ b/web/app/src/bilder.ts @@ -1,5 +1,5 @@ import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api'; -import { knopf, fehlerzeile } from './util'; +import { knopf, fehlerzeile, meldung } from './util'; import { icons } from './icons'; type Bildart = 'poster' | 'backdrop' | 'logo'; @@ -43,6 +43,9 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): document.body.appendChild(d); d.showModal(); + /** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */ + let offen: Bildart = 'poster'; + void laden(); async function laden(): Promise { @@ -51,7 +54,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): auswahl = await bildauswahl(art, id); } catch (fehler) { koerper.innerHTML = ''; - koerper.appendChild(fehlerzeile((fehler as Error).message)); + koerper.appendChild(fehlerzeile(meldung(fehler))); return; } @@ -66,14 +69,51 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): koerper.appendChild(hinweis); } + // **Ein Reiter je Bildtyp statt drei Blöcke untereinander.** + // + // Vorher scrollte der Dialogkörper, und jedes der drei Raster darin + // scrollte noch einmal. Zwei Ebenen übereinander heisst: das Rad trifft + // mal die eine, mal die andere, je nachdem wo der Zeiger steht — und man + // landet nie dort, wo man hinwollte. Jetzt ist genau ein Typ sichtbar und + // genau eine Fläche scrollt. + const reiter = document.createElement('div'); + reiter.className = 'bildreiter'; + reiter.setAttribute('role', 'tablist'); + + const buehne = document.createElement('div'); + buehne.className = 'bildbuehne'; + + koerper.append(reiter, buehne); + + const bloecke = new Map(); + ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => { + const aktuell = bildart === 'poster' ? auswahl.current.poster + : bildart === 'backdrop' ? auswahl.current.backdrop + : auswahl.current.logo; + + const passende = auswahl.candidates.filter(k => k.kind === bildart); + + const knopfReiter = document.createElement('button'); + knopfReiter.type = 'button'; + knopfReiter.className = 'bildreiter-knopf' + (bildart === offen ? ' an' : ''); + knopfReiter.setAttribute('role', 'tab'); + knopfReiter.setAttribute('aria-selected', String(bildart === offen)); + // Die Zahl dazu: so sieht man vor dem Klicken, ob es dort etwas gibt. + knopfReiter.innerHTML = `${name}` + + `${passende.length}`; + knopfReiter.onclick = () => { offen = bildart; void laden(); }; + reiter.appendChild(knopfReiter); + + if (bildart !== offen) return; + const abschnitt = document.createElement('section'); abschnitt.className = 'bildblock'; const kopfzeile = document.createElement('div'); kopfzeile.className = 'knopfreihe'; const u = document.createElement('h3'); - u.textContent = name; + u.textContent = aktuell ? `${name} — eines ist gesetzt` : name; kopfzeile.appendChild(u); // Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil @@ -91,37 +131,34 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): eigenes.disabled = true; try { await bildHochladen(art, id, bildart, datei); await laden(); } catch (fehler) { - abschnitt.appendChild(fehlerzeile((fehler as Error).message)); + abschnitt.appendChild(fehlerzeile(meldung(fehler))); eigenes.disabled = false; } }; kopfzeile.append(eigenes, feld); abschnitt.appendChild(kopfzeile); - const gitter = document.createElement('div'); - gitter.className = 'bildgitter'; - gitter.style.setProperty('--verhaeltnis', verhaeltnis); - - const aktuell = bildart === 'poster' ? auswahl.current.poster - : bildart === 'backdrop' ? auswahl.current.backdrop - : auswahl.current.logo; - - const passende = auswahl.candidates.filter(k => k.kind === bildart); if (passende.length === 0 && !aktuell) { const leer = document.createElement('p'); leer.className = 'winzig'; leer.textContent = 'Nichts zur Auswahl.'; abschnitt.append(leer); - koerper.appendChild(abschnitt); + buehne.appendChild(abschnitt); + bloecke.set(bildart, abschnitt); return; } + const gitter = document.createElement('div'); + gitter.className = 'bildgitter'; + gitter.style.setProperty('--verhaeltnis', verhaeltnis); + // Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht. if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart)); passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart))); abschnitt.appendChild(gitter); - koerper.appendChild(abschnitt); + buehne.appendChild(abschnitt); + bloecke.set(bildart, abschnitt); }); } @@ -154,7 +191,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): b.disabled = true; try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); } catch (fehler) { - b.after(fehlerzeile((fehler as Error).message)); + b.after(fehlerzeile(meldung(fehler))); b.disabled = false; } }; diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 486dbc2..62c23b0 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -1,5 +1,5 @@ import { - bildAdresse, gesehen, favorit, weglegen, istAdmin, + bildAdresse, gesehen, favorit, weglegen, istAdmin, film, serie, type Film, type Serie, type Angefangen, type Stand, } from './api'; import { esc, anteil } from './util'; @@ -274,13 +274,51 @@ function kachelpunkte( // erkannte Titel vor sich hat, soll sie hier geradeziehen können. tun: () => zuordnungsdialog(art, k.id, k.titel, k.zweite && /^\d{4}$/.test(k.zweite) ? Number(k.zweite) : null, - () => location.reload()), + // **Nur die Kachel, nicht die Seite.** `location.reload()` warf + // Bildlauf, Filter und offene Reiter weg — für einen Titel, der sich + // geändert hat. Bei zwanzig Korrekturen hintereinander ist das + // zwanzigmal von vorn anfangen. + () => void kachelAuffrischen(k, knopf)), }); } return punkte; } +/** Holt einen Titel neu und schreibt Beschriftung und Plakat fort. + * + * **Warum nicht die ganze Liste.** Nach einer Korrektur ändert sich genau ein + * Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert + * Titel und setzt die Ansicht an den Anfang zurück. + */ +async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise { + let frisch: Film | Serie; + try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); } + catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung. + + k.titel = frisch.title; + k.zweite = frisch.year ? String(frisch.year) : null; + k.bild = frisch.posterImage; + + const text = knopf.querySelector('.kacheltext'); + if (text) { + text.innerHTML = `${esc(k.titel)}` + + (k.zweite ? `${esc(k.zweite)}` : ''); + } + + const plakat = knopf.querySelector('.plakat'); + const adresse = bildAdresse(k.bild); + plakat?.querySelector('img')?.remove(); + plakat?.classList.remove('geladen'); + if (plakat && adresse) { + const bild = document.createElement('img'); + bild.src = adresse; + bild.alt = ''; + bild.onload = () => plakat.classList.add('geladen'); + plakat.prepend(bild); + } +} + /** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */ function hakenZeigen(knopf: HTMLElement, an: boolean): void { const plakat = knopf.querySelector('.plakat'); diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index d187de6..398543c 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -1,6 +1,7 @@ import { icons } from './icons'; -import { knopf } from './util'; +import { knopf, meldung } from './util'; import { ordner, type Ordnerstand } from './api'; +import { auswahl, type Auswahlfeld } from './auswahl'; /* Dialoge über dem Inhalt statt Blöcken darin. * @@ -71,7 +72,7 @@ export function dialog(optionen: { koerper.appendChild(p); } - const eingaben = new Map(); + const eingaben = new Map(); (optionen.felder ?? []).forEach(f => { // **Ein `