From f359807561926973440569d34a4f3c99e91e132a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 12:15:18 +0200 Subject: [PATCH] =?UTF-8?q?Der=20Fortschritt=20sagt,=20woran=20er=20gerade?= =?UTF-8?q?=20ist=20=E2=80=94=20und=20schreibt=20es=20mit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher stand in der Verwaltung minutenlang "Wird durchsucht…", ohne Hinweis, ob überhaupt etwas passiert. Bei vierzigtausend Dateien ist das nicht zu unterscheiden von einem hängenden Lauf. Scanschritt trägt Phase, Gegenstand und Zählstand und bildet daraus den Satz selbst — im Kern und nicht in der Oberfläche, weil die Apps denselben brauchen und zweimal formuliert er auseinanderdriftet. Sechs Phasen von "Suche Medien in /media/Filme" über "Suche ‚Ich einfach unverbesserlich' bei der Metadatenquelle — 44 von 1204" bis "Räume auf und lege Doppeltes zusammen". Pfade werden auf die letzten zwei Bestandteile gekürzt. ScanAsync und EnrichAsync bekommen je einen optionalen IProgress; optional heisst, dass kein vorhandener Aufrufer bricht. Der Koordinator hält nur den letzten Schritt — eine Liste über vierzigtausend Titel wäre ein Leck im Arbeitsspeicher. Dazu das Tagebuch: eine Datei je Tag unter config/logs, nach lokaler Zeit und nicht nach UTC, weil ein Scan um 23 Uhr am heutigen Datum gesucht wird. Dreissig Tage Aufbewahrung, einstellbar über FLARO_LOG_TAGE — ohne Grenze läuft die config-Partition eines NAS lautlos voll. Es wirft nie: ein volles Dateisystem darf keinen Scan abbrechen. In der Verwaltung lesbar, nach Fehlern filterbar und herunterladbar. Das Containerprotokoll ist nach einem Neustart weg und nur über SSH erreichbar — die Frage "warum wurde der Film gestern Nacht nicht erkannt?" liess sich damit nicht beantworten. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 7 +- .../Endpoints/LibraryContentEndpoints.cs | 3 +- .../Flaro.Api/Endpoints/SystemEndpoints.cs | 29 +++++ server/src/Flaro.Api/Program.cs | 1 + server/src/Flaro.Api/ScanCoordinator.cs | 35 +++++- server/src/Flaro.Api/Tagebuch.cs | 118 ++++++++++++++++++ server/src/Flaro.Core/Library/Scanschritt.cs | 75 +++++++++++ server/src/Flaro.Data/LibraryScanner.cs | 9 +- server/src/Flaro.Data/MetadataEnricher.cs | 29 +++-- web/app/src/api.ts | 10 ++ web/app/src/style.css | 15 +++ web/app/src/views/admin.ts | 96 +++++++++++++- 12 files changed, 411 insertions(+), 16 deletions(-) create mode 100644 server/src/Flaro.Api/Tagebuch.cs create mode 100644 server/src/Flaro.Core/Library/Scanschritt.cs diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 53429e7..1974cb7 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -58,6 +58,9 @@ public sealed record RenameLibraryRequest(string Name); public sealed record CreateLibraryRequest(string Name, string Kind, string Path); +/// Ein Protokolltag. +public sealed record Protokolltag(string Date, long Bytes); + /// Eine Datei, die der letzte Scan nicht mehr gefunden hat. public sealed record VermissteDatei( Guid FileId, @@ -88,7 +91,9 @@ public sealed record ScanStatus( string? Error, MetadataProgress? Metadata, /// Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. - int? Missing = null); + int? Missing = null, + /// Woran der Lauf gerade ist, in einem Satz. + string? Step = null); /// Was das Nachschlagen bei der Metadatenquelle ergeben hat. public sealed record MetadataProgress( diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 495ea95..5ec49c6 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -176,7 +176,8 @@ public static class LibraryContentEndpoints metadaten is null ? null : new MetadataProgress( metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched, metadaten.Skipped, metadaten.Error), - bericht?.Missing)); + bericht?.Missing, + zustand.Schritt?.Text())); }).WithName("Scanstand").RequireAuthorization(); } diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 45c8f1a..169e50d 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -97,6 +97,35 @@ public static class SystemEndpoints Results.Ok(await zusammenlegung.AlleAsync(null, ct))) .RequireAuthorization(FlaroPolicies.Administrator); + // **Protokolle, eine Datei je Tag.** Das Containerprotokoll ist nach + // einem Neustart weg und nur erreichbar, wenn man sich auf das NAS + // verbindet — die Frage „warum wurde der Film gestern Nacht nicht + // erkannt?" lässt sich damit nicht beantworten. + app.MapGet("/api/v1/system/logs", (Tagebuch tagebuch) => + Results.Ok(tagebuch.Tage() + .Select(t => new Protokolltag( + t.ToString("yyyy-MM-dd"), + new FileInfo(tagebuch.DateiFuer(t)) is { Exists: true } f ? f.Length : 0)) + .ToList())) + .RequireAuthorization(FlaroPolicies.Administrator); + + app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch) => + { + // **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.** + // Ein Datum, das durch `DateOnly` gegangen ist, kann kein + // `../../etc/passwd` mehr sein. + if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null, + System.Globalization.DateTimeStyles.None, out var tagesdatum)) + { + return Results.BadRequest(new { fehler = "Erwartet wird ein Datum als JJJJ-MM-TT." }); + } + + var datei = tagebuch.DateiFuer(tagesdatum); + return File.Exists(datei) + ? Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log") + : Results.NotFound(); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => { var name = anfrage.Name.Trim(); diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index be62d2f..90a3394 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -97,6 +97,7 @@ builder.Services.AddSingleton(); // Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage // ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddProblemDetails(); var app = builder.Build(); diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index d5f49cd..cab5b90 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -1,3 +1,4 @@ +using Flaro.Core.Library; using System.Collections.Concurrent; using Flaro.Data; @@ -9,7 +10,15 @@ public sealed record ScanState( DateTimeOffset? FinishedAt, ScanReport? Report, EnrichReport? Enrichment, - string? Error); + string? Error, + /// Woran der Lauf gerade ist — nur der letzte Schritt. + /// + /// + /// Nur der letzte, nicht eine Liste. Der Zustand liegt im + /// Arbeitsspeicher, und eine wachsende Liste über vierzigtausend Titel + /// wäre ein Leck. Wer die Vorgeschichte braucht, liest das Protokoll. + /// + Scanschritt? Schritt = null); /// Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind. /// @@ -68,8 +77,22 @@ public sealed class ScanCoordinator( try { using var bereich = scopes.CreateScope(); + + // **Jeder Schritt landet im Zustand und im Protokoll.** Der + // Zustand ist für die Oberfläche, das Protokoll für die Frage + // „was war gestern Nacht?" — die ein Zustand im Arbeitsspeicher + // nach einem Neustart nicht mehr beantworten kann. + var tagebuch = bereich.ServiceProvider.GetRequiredService(); + var melder = new Progress(schritt => + { + _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(); - var bericht = await scanner.ScanAsync(libraryId); + var bericht = await scanner.ScanAsync(libraryId, melder); // Der Scan ist durch, die Dateien stehen in der Mediathek. Was // jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren @@ -77,7 +100,7 @@ public sealed class ScanCoordinator( _zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht }; var metadaten = await bereich.ServiceProvider - .GetRequiredService().EnrichAsync(libraryId, erneut); + .GetRequiredService().EnrichAsync(libraryId, erneut, melder); // **Erst jetzt lassen sich Dubletten überhaupt erkennen.** // Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" @@ -99,11 +122,17 @@ public sealed class ScanCoordinator( Status = "finished", FinishedAt = clock.GetUtcNow(), Enrichment = metadaten, + Schritt = null, }; } catch (Exception fehler) { 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); + } _zustand[libraryId] = _zustand[libraryId] with { Status = "failed", diff --git a/server/src/Flaro.Api/Tagebuch.cs b/server/src/Flaro.Api/Tagebuch.cs new file mode 100644 index 0000000..4460934 --- /dev/null +++ b/server/src/Flaro.Api/Tagebuch.cs @@ -0,0 +1,118 @@ +using System.Globalization; +using System.Text; + +namespace Flaro.Api; + +/// Schreibt mit, was der Server tut — eine Datei je Tag. +/// +/// +/// +/// Wofür, wenn es doch ein Containerprotokoll gibt. Das ist nach einem +/// Neustart weg, und um es überhaupt zu sehen, muss man sich auf das NAS +/// verbinden. Die Frage „warum wurde der Film gestern Nacht nicht erkannt?" +/// lässt sich damit nicht beantworten. Diese Dateien liegen im +/// config-Volume, überleben jeden Neustart und sind aus dem Browser +/// lesbar. +/// +/// +/// Eine Datei je Tag, nach lokaler Zeit. Nicht nach UTC: wer um +/// 23 Uhr einen Scan anstösst, sucht ihn am heutigen Datum, nicht am +/// morgigen. Die Zeitzone kommt aus TZ, so wie überall sonst im +/// Container auch. +/// +/// +/// Mit Aufbewahrungsgrenze. Ohne sie läuft die config-Partition +/// eines NAS irgendwann voll, und zwar lautlos. +/// +/// +public sealed class Tagebuch +{ + public enum Art { Info, Warnung, Fehler } + + private readonly string _ordner; + private readonly TimeProvider _uhr; + private readonly int _tageAufbewahren; + + /// Ein Schreiber je Prozess — zwei gleichzeitige Zeilen vermischen sich sonst. + private readonly Lock _schloss = new(); + + private DateOnly _zuletztAufgeraeumt = DateOnly.MinValue; + + public Tagebuch(FlaroPaths pfade, TimeProvider uhr) + { + _ordner = Path.Combine(pfade.Data, "logs"); + _uhr = uhr; + _tageAufbewahren = int.TryParse( + Environment.GetEnvironmentVariable("FLARO_LOG_TAGE"), out var tage) && tage > 0 + ? tage + : 30; + + Directory.CreateDirectory(_ordner); + } + + /// Die Datei für einen Tag — auch dann, wenn es sie nicht gibt. + public string DateiFuer(DateOnly tag) => + Path.Combine(_ordner, $"flaro-{tag:yyyy-MM-dd}.log"); + + /// Welche Tage protokolliert sind, neueste zuerst. + public IReadOnlyList Tage() + { + if (!Directory.Exists(_ordner)) return []; + + return Directory.EnumerateFiles(_ordner, "flaro-*.log") + .Select(p => Path.GetFileNameWithoutExtension(p)["flaro-".Length..]) + .Select(t => DateOnly.TryParseExact(t, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var tag) ? tag : (DateOnly?)null) + .Where(t => t is not null) + .Select(t => t!.Value) + .OrderByDescending(t => t) + .ToList(); + } + + /// Hängt eine Zeile an das Protokoll des heutigen Tages. + /// + /// + /// Wirft nie. Ein volles Dateisystem oder ein schreibgeschütztes + /// Volume darf einen Scan nicht abbrechen — das Protokoll ist Beiwerk, und + /// Beiwerk, das den Betrieb anhält, ist schlechter als keines. + /// + public void Schreiben(string text, Art art = Art.Info) + { + try + { + var jetzt = _uhr.GetLocalNow(); + var heute = DateOnly.FromDateTime(jetzt.DateTime); + var stufe = art switch + { + Art.Fehler => "FEHLER ", + Art.Warnung => "WARNUNG", + _ => "INFO ", + }; + + lock (_schloss) + { + File.AppendAllText( + DateiFuer(heute), + $"{jetzt:HH:mm:ss} {stufe} {text}{Environment.NewLine}", + Encoding.UTF8); + + Aufraeumen(heute); + } + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + /// Löscht, was älter ist als die Aufbewahrungsfrist — höchstens einmal je Tag. + private void Aufraeumen(DateOnly heute) + { + if (_zuletztAufgeraeumt == heute) return; + _zuletztAufgeraeumt = heute; + + var grenze = heute.AddDays(-_tageAufbewahren); + foreach (var tag in Tage().Where(t => t < grenze)) + { + try { File.Delete(DateiFuer(tag)); } catch (IOException) { } + } + } +} diff --git a/server/src/Flaro.Core/Library/Scanschritt.cs b/server/src/Flaro.Core/Library/Scanschritt.cs new file mode 100644 index 0000000..33be573 --- /dev/null +++ b/server/src/Flaro.Core/Library/Scanschritt.cs @@ -0,0 +1,75 @@ +namespace Flaro.Core.Library; + +/// Woran ein Lauf gerade ist. +public enum Scanphase +{ + /// Das Dateisystem wird durchgegangen. + Suchen, + + /// Eine Datei wird gelesen und vermessen. + Pruefen, + + /// Ein Titel wird bei der Metadatenquelle gesucht. + Nachschlagen, + + /// Die Angaben zu einem gefundenen Titel werden geholt. + Holen, + + /// Plakate und Hintergründe werden geladen. + Bilder, + + /// Verwaistes wird entfernt, Doppeltes zusammengelegt. + Aufraeumen, +} + +/// Ein Zwischenstand: was gerade passiert, und wie weit. +/// +/// +/// +/// Mit Namen, nicht nur mit Zahlen. Vorher stand in der Verwaltung +/// minutenlang „Wird durchsucht…" — ohne Hinweis, ob überhaupt etwas +/// passiert. Wer bei vierzigtausend Dateien zusieht, braucht zu sehen, dass +/// sich etwas bewegt, und woran es gerade hängt, wenn es hängt. +/// +/// +/// Ein record struct, weil davon im Sekundentakt viele entstehen und +/// keiner davon überlebt. +/// +/// +/// Welcher Abschnitt des Laufs. +/// Woran genau — ein Titel oder ein Pfad. +/// Wie viele Stücke schon durch sind. +/// Wie viele es insgesamt sind; 0, wenn unbekannt. +public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erledigt = 0, int Gesamt = 0) +{ + /// Der Satz, der in der Oberfläche steht. + /// + /// + /// Hier und nicht in der Oberfläche. Die Apps brauchen denselben + /// Satz, und zweimal formuliert driftet er auseinander. + /// + public string Text() + { + var zaehler = Gesamt > 0 ? $" — {Erledigt} von {Gesamt}" : ""; + + return Phase switch + { + Scanphase.Suchen => $"Suche Medien in {Kurz(Was)}", + Scanphase.Pruefen => $"Prüfe {Kurz(Was)}{zaehler}", + Scanphase.Nachschlagen => $"Suche „{Was}“ bei der Metadatenquelle{zaehler}", + Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}", + Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}", + Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen", + _ => Was, + }; + } + + /// Die letzten zwei Pfadbestandteile — ein ganzer Pfad sprengt die Zeile. + private static string Kurz(string pfad) + { + if (string.IsNullOrEmpty(pfad)) return pfad; + + var teile = pfad.Split('/', StringSplitOptions.RemoveEmptyEntries); + return teile.Length <= 2 ? pfad : string.Join('/', teile[^2..]); + } +} diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index 84bbd67..5238a9b 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -38,7 +38,8 @@ public sealed class BestandVerschwundenException(string nachricht) : Exception(n public sealed class LibraryScanner( FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger log) { - public async Task ScanAsync(Guid libraryId, CancellationToken ct = default) + public async Task ScanAsync( + Guid libraryId, IProgress? fortschritt = null, CancellationToken ct = default) { var bibliothek = await db.Libraries.FindAsync([libraryId], ct) ?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}."); @@ -60,6 +61,7 @@ public sealed class LibraryScanner( "Es wurde nichts verändert."); } + fortschritt?.Report(new Scanschritt(Scanphase.Suchen, bibliothek.Path)); var ergebnis = LibraryWalker.Walk(bibliothek.Path); if (ergebnis.Files.Count == 0 && warVorherDa > 0) @@ -93,9 +95,13 @@ public sealed class LibraryScanner( int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0; var seitLetztemSchreiben = 0; + var durch = 0; + foreach (var datei in ergebnis.Files) { ct.ThrowIfCancellationRequested(); + fortschritt?.Report(new Scanschritt( + Scanphase.Pruefen, datei.Path, ++durch, ergebnis.Files.Count)); Guid? filmId = null, folgeId = null; @@ -196,6 +202,7 @@ public sealed class LibraryScanner( .Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit && f.MissingSince == null) .ExecuteUpdateAsync(s => s.SetProperty(f => f.MissingSince, laufZeit), ct); + fortschritt?.Report(new Scanschritt(Scanphase.Aufraeumen, "")); await VerwaisteAufraeumen(libraryId, ct); log.LogInformation( diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 8eb67f9..0def7af 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -43,7 +43,8 @@ public sealed class MetadataEnricher( /// Suche. Eine Zuordnung von Hand bleibt damit stehen. /// public async Task EnrichAsync( - Guid libraryId, bool erneut = false, CancellationToken ct = default) + Guid libraryId, bool erneut = false, + IProgress? fortschritt = null, CancellationToken ct = default) { if (!await anbieter.IsConfiguredAsync(ct)) { @@ -54,9 +55,9 @@ public sealed class MetadataEnricher( int filme = 0, serien = 0, folgen = 0; try { - filme = await FilmeZuordnen(libraryId, erneut, ct); - serien = await SerienZuordnen(libraryId, erneut, ct); - folgen = await FolgenZuordnen(libraryId, erneut, ct); + filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct); + serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct); + folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct); } catch (MetadataCredentialsException fehler) { @@ -71,16 +72,20 @@ public sealed class MetadataEnricher( return new EnrichReport(filme, serien, folgen, Skipped: false, null); } - private async Task FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct) + private async Task FilmeZuordnen( + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) { var offen = await db.Movies .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)) .ToListAsync(ct); var zugeordnet = 0; + var durch = 0; foreach (var film in offen) { ct.ThrowIfCancellationRequested(); + fortschritt?.Report(new Scanschritt( + Scanphase.Nachschlagen, film.Title, ++durch, offen.Count)); var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct); var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct); @@ -142,16 +147,20 @@ public sealed class MetadataEnricher( return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo)); } - private async Task SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct) + private async Task SerienZuordnen( + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) { var offen = await db.Series .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)) .ToListAsync(ct); var zugeordnet = 0; + var durch = 0; foreach (var serie in offen) { ct.ThrowIfCancellationRequested(); + fortschritt?.Report(new Scanschritt( + Scanphase.Nachschlagen, serie.Title, ++durch, offen.Count)); // Bei einer Serie liegt die NFO neben der Folge, also im // Staffelordner — gesucht wird deshalb ab der ersten Folgendatei. @@ -230,7 +239,8 @@ public sealed class MetadataEnricher( } /// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind. - private async Task FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct) + private async Task FolgenZuordnen( + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) { var serien = await db.Series .Where(s => s.LibraryId == libraryId && s.TmdbId != null) @@ -244,8 +254,13 @@ public sealed class MetadataEnricher( .ToListAsync(ct); var zugeordnet = 0; + var durch = 0; foreach (var folge in offen) { + fortschritt?.Report(new Scanschritt( + Scanphase.Holen, + $"Folge {folge.SeasonNumber}x{folge.EpisodeNumber:00}", ++durch, offen.Count)); + ct.ThrowIfCancellationRequested(); var serie = serien.First(s => s.Id == folge.SeriesId); diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 4fbb263..80febbc 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -85,6 +85,8 @@ export type Scanstand = { removed: number | null; unrecognised: number | null; probed: number | null; /** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */ missing: number | null; + /** Woran der Lauf gerade ist, in einem Satz. */ + step: string | null; problems: { path: string; reason: string }[] | null; error: string | null; metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number; skipped: boolean; error: string | null } | null; @@ -430,6 +432,14 @@ export type Ordnerstand = { note: string | null; }; +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}`; + export type VermissteDatei = { fileId: string; path: string; versionLabel: string | null; missingSince: string; libraryName: string; diff --git a/web/app/src/style.css b/web/app/src/style.css index 737d433..b9b1412 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -634,3 +634,18 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var( max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } + +/* Protokollanzeige — Festbreitenschrift, begrenzte Höhe, eigener Bildlauf. */ +.protokoll { + margin: var(--s-regular) 0 0; + padding: var(--s-regular); + background: var(--canvas); + border: 1px solid var(--line); + border-radius: var(--r-control); + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--text-dim); + max-height: 420px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 880235a..73b3359 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -2,6 +2,7 @@ import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, vermisste, vermisstesEntfernen, type VermissteDatei, + protokolltage, protokollAdresse, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, @@ -26,7 +27,8 @@ export function verwaltung( ): HTMLElement { const wurzel = document.createElement('div'); wurzel.append(dashboard(signal), vermisstenbereich(geaendert), - bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert)); + bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert), + protokollbereich()); return wurzel; } @@ -158,6 +160,92 @@ function laufzeit(sekunden: number): string { return `${minuten} Min.`; } +/* ---------- Protokolle ---------- */ + +/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS. + * + * Das Containerprotokoll ist nach einem Neustart weg und nur über SSH + * erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?" + * lässt sich damit nicht beantworten. */ +function protokollbereich(): HTMLElement { + const bereich = document.createElement('section'); + bereich.appendChild(abschnitt('Protokolle')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + liste.innerHTML = '
'; + bereich.appendChild(liste); + + const anzeige = document.createElement('pre'); + anzeige.className = 'protokoll hidden'; + bereich.appendChild(anzeige); + + void (async () => { + let tage; + try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; } + + liste.innerHTML = ''; + if (tage.length === 0) { + liste.appendChild(leer('Noch nichts protokolliert.')); + return; + } + + tage.forEach(t => { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + const datum = new Date(t.date + 'T12:00:00').toLocaleDateString('de-CH', + { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }); + text.innerHTML = `${esc(datum)}` + + `${Math.max(1, Math.round(t.bytes / 1024))} KB`; + oben.appendChild(text); + + const lesen = knopf('Ansehen', 'btn'); + lesen.onclick = () => void zeigen(t.date, false); + const nurFehler = knopf('Nur Fehler', 'btn'); + nurFehler.onclick = () => void zeigen(t.date, true); + const holen = document.createElement('a'); + holen.className = 'btn'; + holen.href = protokollAdresse(t.date); + holen.download = `flaro-${t.date}.log`; + holen.textContent = 'Herunterladen'; + oben.append(lesen, nurFehler, holen); + + d.appendChild(oben); + liste.appendChild(d); + }); + })(); + + async function zeigen(tag: string, nurFehler: boolean): Promise { + anzeige.classList.remove('hidden'); + anzeige.textContent = 'Wird geladen…'; + try { + const antwort = await fetch(protokollAdresse(tag)); + if (!antwort.ok) throw new Error(`${antwort.status}`); + const text = await antwort.text(); + const zeilen = text.split('\n'); + // **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann + // zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte. + const gefiltert = nurFehler + ? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG')) + : zeilen; + anzeige.textContent = gefiltert.length === 0 + ? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.') + : gefiltert.slice(-800).join('\n'); + anzeige.scrollTop = anzeige.scrollHeight; + } catch (fehler) { + anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`; + } + } + + return bereich; +} + /* ---------- Nicht mehr gefunden ---------- */ /** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt. @@ -362,8 +450,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle for (let i = 0; i < 900; i++) { if (signal.aborted) return; const s = await scanstand(b.id); - if (s.state === 'running') stand.textContent = 'Wird durchsucht…'; - else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…'; + // **Was der Server sagt, nicht was wir raten.** Vorher stand hier + // minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert. + if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…'; + else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…'; else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; } else if (s.state === 'finished') { const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];