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`];