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