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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..]);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<Protokolltag[]>('/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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '<div class="spinner"></div>';
|
||||
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 = `<strong>${esc(datum)}</strong>`
|
||||
+ `<span class="winzig">${Math.max(1, Math.round(t.bytes / 1024))} KB</span>`;
|
||||
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<void> {
|
||||
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`];
|
||||
|
||||
Reference in New Issue
Block a user