Der Fortschritt sagt, woran er gerade ist — und schreibt es mit

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:15:18 +02:00
co-authored by Claude Opus 5
parent 637e95fbb1
commit f359807561
12 changed files with 411 additions and 16 deletions
+6 -1
View File
@@ -58,6 +58,9 @@ public sealed record RenameLibraryRequest(string Name);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
/// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes);
/// <summary>Eine Datei, die der letzte Scan nicht mehr gefunden hat.</summary>
public sealed record VermissteDatei(
Guid FileId,
@@ -88,7 +91,9 @@ public sealed record ScanStatus(
string? Error,
MetadataProgress? Metadata,
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
int? Missing = null);
int? Missing = null,
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
string? Step = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
@@ -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();
}
@@ -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();
+1
View File
@@ -97,6 +97,7 @@ builder.Services.AddSingleton<PackageCoordinator>();
// 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<PlaybackSessions>();
builder.Services.AddSingleton<Tagebuch>();
builder.Services.AddProblemDetails();
var app = builder.Build();
+32 -3
View File
@@ -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,
/// <summary>Woran der Lauf gerade ist — nur der letzte Schritt.</summary>
///
/// <remarks>
/// <b>Nur der letzte, nicht eine Liste.</b> Der Zustand liegt im
/// Arbeitsspeicher, und eine wachsende Liste über vierzigtausend Titel
/// wäre ein Leck. Wer die Vorgeschichte braucht, liest das Protokoll.
/// </remarks>
Scanschritt? Schritt = null);
/// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary>
///
@@ -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<Tagebuch>();
var melder = new Progress<Scanschritt>(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<LibraryScanner>();
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<MetadataEnricher>().EnrichAsync(libraryId, erneut);
.GetRequiredService<MetadataEnricher>().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<Tagebuch>()
.Schreiben($"Scan gescheitert: {fehler.Message}", Tagebuch.Art.Fehler);
}
_zustand[libraryId] = _zustand[libraryId] with
{
Status = "failed",
+118
View File
@@ -0,0 +1,118 @@
using System.Globalization;
using System.Text;
namespace Flaro.Api;
/// <summary>Schreibt mit, was der Server tut — eine Datei je Tag.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür, wenn es doch ein Containerprotokoll gibt.</b> 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
/// <c>config</c>-Volume, überleben jeden Neustart und sind aus dem Browser
/// lesbar.
/// </para>
/// <para>
/// <b>Eine Datei je Tag, nach lokaler Zeit.</b> Nicht nach UTC: wer um
/// 23 Uhr einen Scan anstösst, sucht ihn am heutigen Datum, nicht am
/// morgigen. Die Zeitzone kommt aus <c>TZ</c>, so wie überall sonst im
/// Container auch.
/// </para>
/// <para>
/// <b>Mit Aufbewahrungsgrenze.</b> Ohne sie läuft die <c>config</c>-Partition
/// eines NAS irgendwann voll, und zwar lautlos.
/// </para>
/// </remarks>
public sealed class Tagebuch
{
public enum Art { Info, Warnung, Fehler }
private readonly string _ordner;
private readonly TimeProvider _uhr;
private readonly int _tageAufbewahren;
/// <summary>Ein Schreiber je Prozess — zwei gleichzeitige Zeilen vermischen sich sonst.</summary>
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);
}
/// <summary>Die Datei für einen Tag — auch dann, wenn es sie nicht gibt.</summary>
public string DateiFuer(DateOnly tag) =>
Path.Combine(_ordner, $"flaro-{tag:yyyy-MM-dd}.log");
/// <summary>Welche Tage protokolliert sind, neueste zuerst.</summary>
public IReadOnlyList<DateOnly> 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();
}
/// <summary>Hängt eine Zeile an das Protokoll des heutigen Tages.</summary>
///
/// <remarks>
/// <b>Wirft nie.</b> 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.
/// </remarks>
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) { }
}
/// <summary>Löscht, was älter ist als die Aufbewahrungsfrist — höchstens einmal je Tag.</summary>
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) { }
}
}
}
@@ -0,0 +1,75 @@
namespace Flaro.Core.Library;
/// <summary>Woran ein Lauf gerade ist.</summary>
public enum Scanphase
{
/// <summary>Das Dateisystem wird durchgegangen.</summary>
Suchen,
/// <summary>Eine Datei wird gelesen und vermessen.</summary>
Pruefen,
/// <summary>Ein Titel wird bei der Metadatenquelle gesucht.</summary>
Nachschlagen,
/// <summary>Die Angaben zu einem gefundenen Titel werden geholt.</summary>
Holen,
/// <summary>Plakate und Hintergründe werden geladen.</summary>
Bilder,
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
Aufraeumen,
}
/// <summary>Ein Zwischenstand: was gerade passiert, und wie weit.</summary>
///
/// <remarks>
/// <para>
/// <b>Mit Namen, nicht nur mit Zahlen.</b> 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.
/// </para>
/// <para>
/// Ein <c>record struct</c>, weil davon im Sekundentakt viele entstehen und
/// keiner davon überlebt.
/// </para>
/// </remarks>
/// <param name="Phase">Welcher Abschnitt des Laufs.</param>
/// <param name="Was">Woran genau — ein Titel oder ein Pfad.</param>
/// <param name="Erledigt">Wie viele Stücke schon durch sind.</param>
/// <param name="Gesamt">Wie viele es insgesamt sind; <c>0</c>, wenn unbekannt.</param>
public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erledigt = 0, int Gesamt = 0)
{
/// <summary>Der Satz, der in der Oberfläche steht.</summary>
///
/// <remarks>
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen denselben
/// Satz, und zweimal formuliert driftet er auseinander.
/// </remarks>
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,
};
}
/// <summary>Die letzten zwei Pfadbestandteile — ein ganzer Pfad sprengt die Zeile.</summary>
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..]);
}
}
+8 -1
View File
@@ -38,7 +38,8 @@ public sealed class BestandVerschwundenException(string nachricht) : Exception(n
public sealed class LibraryScanner(
FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
{
public async Task<ScanReport> ScanAsync(Guid libraryId, CancellationToken ct = default)
public async Task<ScanReport> ScanAsync(
Guid libraryId, IProgress<Scanschritt>? 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(
+22 -7
View File
@@ -43,7 +43,8 @@ public sealed class MetadataEnricher(
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
/// </param>
public async Task<EnrichReport> EnrichAsync(
Guid libraryId, bool erneut = false, CancellationToken ct = default)
Guid libraryId, bool erneut = false,
IProgress<Scanschritt>? 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<int> FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
private async Task<int> FilmeZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? 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<int> SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
private async Task<int> SerienZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? 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(
}
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
private async Task<int> FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
private async Task<int> FolgenZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? 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);