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:
co-authored by
Claude Opus 5
parent
637e95fbb1
commit
f359807561
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user