Serie für Serie, Film für Film — statt einer Zeile mit einer GUID

Die Markensuche lief stundenlang über 95 Serien, 4213 Folgen und 453
Filme und schrieb dabei fünf Zeilen, in denen Kennungen standen statt
Namen. „Markenerkennung für 4f3a…: 12 Marken." beantwortet keine der
Fragen, die in den letzten Tagen tatsächlich aufkamen — welche Staffel
nichts bekommen hat, warum, und ob die Serie überhaupt dran war.

Jetzt nennt jede Zeile den Titel, und es gibt eine je Serie, je Staffel
und je Folge:

    „Biohackers": 2 Staffel(n), 18 Folge(n).
    „Biohackers", Staffel 2: Vorspann in 8 von 8 Folge(n), Abspann in 1 von 8.
    Biohackers S02E01 · Neuanfang: Vorspann 0:31–1:24, Abspann ab 41:12 (über das Bild).
    Biohackers S02E07 · Letzte Fahrt: Vorspann 0:31–1:24, kein Abspann.
    „Biohackers" fertig: 14 Marke(n).

Die Folgenzeilen werden **aus der Datenbank gelesen, nicht mitgezählt**:
so steht dort, was danach wirklich an der Folge hängt, und nicht, was
dieser eine Lauf geschrieben hat. Nur das beantwortet „warum kommt bei
dieser Folge kein Knopf?".

**Drei Gründe statt eines `false`.** `FilmAsync` gab für „keine Datei",
„schon durchsucht" und „nichts gefunden" dasselbe zurück, und von aussen
waren sie nicht zu unterscheiden. Jetzt sagt jeder seinen Namen.

**Und eine Live-Zeile bei der Mediathek**, wie der Scan sie hat. Sie
benutzt denselben Mechanismus — `Scanschritt` mit einer neuen Phase,
derselbe Endpunkt, ein eigenes Feld. Ein eigenes, weil der Scan sich auf
„fertig" setzt, sobald er die Markensuche angestossen hat; in sein Feld
geschrieben sähe er wieder laufend aus.

**Zwei Fehler sind dabei herausgefallen.** In `NachScanAsync` stand
`if (serien.Count == 0) return;` vor dem Filmteil — eine reine
Filmmediathek bekam nach einem Scan also nie eine Abspannsuche. Und die
fpcalc-Prüfung stand ganz oben und galt für beides, obwohl der Bildweg
fpcalc gar nicht braucht: ohne fpcalc fand der Server keinen einzigen
Filmabspann.

Dazu ein Suchfeld im Protokoll und tausend Anfangszeilen statt
dreihundert. Ohne das wäre eine Zeile je Folge keine Auskunft, sondern
eine Mauer — genau der Fall, gegen den sich `ScanCoordinator` für den
Scan schon einmal entschieden hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 13:11:58 +02:00
co-authored by Claude Opus 5
parent 70ab4cc421
commit 767491ef03
8 changed files with 460 additions and 77 deletions
+10 -1
View File
@@ -322,7 +322,16 @@ public sealed record ScanStatus(
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
int? Missing = null,
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
string? Step = null);
string? Step = null,
/// <summary>Woran die Markensuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
///
/// <remarks>
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Markensuche
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
/// </remarks>
string? Marken = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
@@ -416,11 +416,21 @@ public static class LibraryContentEndpoints
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
app.MapGet("/api/v1/libraries/{id:guid}/scan",
(Guid id, ScanCoordinator koordinator, Markenerkennung erkennung) =>
{
// **Auch ohne Scan kann etwas laufen.** Die Markensuche lässt
// sich von Hand anstossen; ohne diese Zeile stünde die
// Verwaltung dann auf „idle", während der Server stundenlang
// arbeitet.
var marken = erkennung.Stand(id)?.Text();
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
return Results.Ok(new ScanStatus(
marken is null ? "idle" : "marken",
null, null, null, null, null, null, null, null, null, null, null,
Marken: marken));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
@@ -434,7 +444,8 @@ public static class LibraryContentEndpoints
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
metadaten.Skipped, metadaten.Error),
bericht?.Missing,
zustand.Schritt?.Text()));
zustand.Schritt?.Text(),
marken));
}).WithName("Scanstand").RequireAuthorization();
}
+278 -38
View File
@@ -84,6 +84,26 @@ public sealed class Markenerkennung(
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
/// <summary>Woran der Lauf je Mediathek gerade ist — für die Verwaltung.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
/// Markenerkennung angestossen hat — in dasselbe Feld zu schreiben
/// liesse einen beendeten Scan wieder laufend aussehen.
/// </para>
/// <para>
/// <b>Nur der letzte Schritt</b>, wie bei <c>ScanState.Schritt</c>: eine
/// Liste über viertausend Folgen wäre ein Leck, und wer die
/// Vorgeschichte braucht, liest das Protokoll.
/// </para>
/// </remarks>
private readonly ConcurrentDictionary<Guid, Scanschritt> _stand = new();
public Scanschritt? Stand(Guid mediathek) =>
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
public bool Starten(Guid serieId, bool erneut = false)
{
@@ -131,32 +151,97 @@ public sealed class Markenerkennung(
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
if (!await leser.VorhandenAsync(ct)) return;
var mediathekname = await db.Libraries.AsNoTracking()
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct)
?? "Mediathek";
var serien = await db.Series.AsNoTracking()
.Where(x => x.LibraryId == mediathek)
.Select(x => x.Id)
.Select(x => new { x.Id, x.Title })
.OrderBy(x => x.Title)
.ToListAsync(ct);
if (serien.Count == 0) return;
try
{
// **Ohne fpcalc nur die Serien überspringen, nicht alles.** Der
// Bildweg braucht es nicht; ein Server ohne fpcalc konnte früher
// trotzdem keinen einzigen Filmabspann finden, weil diese
// Prüfung ganz oben stand und für beides galt.
if (serien.Count > 0 && await leser.VorhandenAsync(ct))
{
await SerienAsync(db, mediathekname, serien
.Select(s => (s.Id, s.Title)).ToList(), mediathek, erneut, ct);
}
else if (serien.Count > 0)
{
log.LogInformation(
"Kein fpcalc vorhanden — in „{Mediathek}“ bleibt der Vorspann ungesucht.",
mediathekname);
}
// **Nicht mehr hinter einem `return` für serienlose Mediatheken.**
// Stand hier früher `if (serien.Count == 0) return;`, und damit
// kam eine reine Filmmediathek nie bis zu dieser Zeile: der
// Abspann eines Films wurde nach einem Scan nie gesucht.
await FilmeAsync(db, mediathekname, mediathek, erneut, ct,
schritt => _stand[mediathek] = schritt);
}
finally
{
_stand.TryRemove(mediathek, out _);
}
}
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
private async Task SerienAsync(
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien,
Guid mediathek, bool erneut, CancellationToken ct)
{
var folgen = await db.Episodes.AsNoTracking()
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
log.LogInformation(
"Suche Vorspann und Abspann in {Anzahl} Serien — das kann dauern.", serien.Count);
"Markensuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
+ "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
foreach (var serie in serien)
var marken = 0;
var ohneFund = 0;
var angefasst = 0;
for (var i = 0; i < serien.Count; i++)
{
if (ct.IsCancellationRequested) return;
if (!_laufend.TryAdd(serie, true)) continue;
try { await LaufenAsync(serie, erneut, ct); }
var (id, titel) = serien[i];
var nummer = i + 1;
// Der Zähler gehört der Mediathek, der Name der Folge: so steht
// in der Zeile beides — wie weit, und woran gerade.
void Melden(Scanschritt schritt) =>
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count };
Melden(new Scanschritt(Scanphase.Marken, titel));
if (!_laufend.TryAdd(id, true)) continue;
try
{
var stand = await LaufenAsync(id, erneut, ct, Melden);
marken += stand.Marken;
if (!stand.Angefasst) continue;
angefasst++;
if (stand.Marken == 0) ohneFund++;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serie);
log.LogWarning(fehler, "Markensuche für „{Serie}“ abgebrochen.", titel);
}
finally { _laufend.TryRemove(serie, out _); }
finally { _laufend.TryRemove(id, out _); }
}
await FilmeAsync(db, mediathek, erneut, ct);
log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek);
log.LogInformation(
"Markensuche in „{Mediathek}“ durch: {Marken} Marke(n) in {Angefasst} von "
+ "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.",
mediathekname, marken, angefasst, serien.Count, ohneFund);
}
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
@@ -189,7 +274,8 @@ public sealed class Markenerkennung(
/// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks>
private async Task FilmeAsync(
FlaroContext db, Guid mediathek, bool erneut, CancellationToken ct)
FlaroContext db, string mediathekname, Guid mediathek, bool erneut,
CancellationToken ct, Action<Scanschritt> melden)
{
var offen = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == mediathek)
@@ -197,27 +283,41 @@ public sealed class Markenerkennung(
// dafür drückt jemand den Knopf.
.Where(m => erneut
|| !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
.Select(m => m.Id)
.Select(m => new { m.Id, m.Title, m.Year })
.OrderBy(m => m.Title)
.ToListAsync(ct);
if (offen.Count == 0) return;
log.LogInformation("Suche den Abspann in {Anzahl} Filmen.", offen.Count);
log.LogInformation("Abspannsuche in „{Mediathek}“: {Anzahl} Film(e).",
mediathekname, offen.Count);
var gefunden = 0;
foreach (var film in offen)
for (var i = 0; i < offen.Count; i++)
{
if (ct.IsCancellationRequested) return;
try { if (await FilmAsync(film, erneut, ct)) gefunden++; }
var film = offen[i];
var titel = Filmname(film.Title, film.Year);
melden(new Scanschritt(Scanphase.Marken, titel, i + 1, offen.Count));
try { if (await FilmAsync(film.Id, erneut, ct)) gefunden++; }
catch (Exception fehler)
{
log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film);
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen.", titel);
}
}
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Filmen gefunden.",
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Film(en) gefunden.",
gefunden, offen.Count);
}
/// <summary>„Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.</summary>
private static string Filmname(string? titel, int? jahr)
{
var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
return jahr is { } j ? $"{name} ({j})" : name;
}
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
///
/// <remarks>
@@ -244,20 +344,38 @@ public sealed class Markenerkennung(
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == filmId)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Id, d.Path, d.Duration })
(m, d) => new { m.Id, m.Title, m.Year, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
// Ohne Datei nichts abzutasten, ohne Laufzeit kein Ende zu finden.
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) return false;
// **Drei Gründe, nicht einer.** Hier stand dreimal dasselbe `false`
// — keine Datei, schon durchsucht, nichts gefunden —, und von
// aussen waren die drei nicht zu unterscheiden. „Warum hat dieser
// Film keinen Abspann?" liess sich damit nicht beantworten.
if (film is null)
{
log.LogInformation("Abspann: zu {Kennung} liegt keine Datei — übersprungen.", filmId);
return false;
}
var titel = Filmname(film.Title, film.Year);
if (film.Duration is not { TotalSeconds: > 0 } laufzeit)
{
log.LogInformation("„{Film}“: keine Laufzeit in der Datei — übersprungen.", titel);
return false;
}
if (!erneut && await db.Markensuchen.AsNoTracking()
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
{
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel);
return false;
}
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
var gefunden = await BildabspannAsync(
db, abtaster, filmId, istFilm: true, film.Path, laufzeit.TotalSeconds, ct);
db, abtaster, filmId, istFilm: true, titel, film.Path, laufzeit.TotalSeconds, ct);
if (!gefunden)
{
@@ -291,9 +409,14 @@ public sealed class Markenerkennung(
/// wie jede andere Folge.
/// </para>
/// </remarks>
/// <param name="name">
/// Wie der Titel im Protokoll heissen soll — „Arrival (2016)" oder
/// „Biohackers S02E01". Diese Stelle kennt sonst nur den Dateipfad, und
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param>
private async Task<bool> BildabspannAsync(
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
string pfad, double laufzeit, CancellationToken ct)
string name, string pfad, double laufzeit, CancellationToken ct)
{
var ab = Math.Max(0, laufzeit - Suchbereich);
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
@@ -307,9 +430,9 @@ public sealed class Markenerkennung(
if (Abspannkante.Suchen(proben, regeln) is not { } stelle)
{
log.LogInformation(
"Kein Abspann in {Pfad}: {Proben} Proben, Schwelle {Schwelle:0.#}, "
"„{Titel}“: kein Abspann — {Proben} Probe(n), Schwelle {Schwelle:0.#}, "
+ "letzte Probe {Letzte:0.#}.",
pfad, proben.Count, Abspannkante.Schwelle(proben, regeln),
name, proben.Count, Abspannkante.Schwelle(proben, regeln),
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
return false;
}
@@ -319,22 +442,41 @@ public sealed class Markenerkennung(
hell, stelle, Abspannkante.Schwelle(proben, regeln),
wo => abtaster.HelligkeitAsync(pfad, wo, ct));
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(kante));
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
kante, laufzeit, ct);
}
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
///
/// <param name="Marken">Wie viele Marken geschrieben wurden.</param>
/// <param name="Angefasst">
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
/// erst gefragt, und beides in einen Topf zu werfen machte die
/// Zusammenfassung wertlos.
/// </param>
private readonly record struct Serienstand(int Marken, bool Angefasst);
private async Task<Serienstand> LaufenAsync(
Guid serieId, bool erneut, CancellationToken ct, Action<Scanschritt>? melden = null)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var serienname = await db.Series.AsNoTracking()
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
?? "Ohne Titel";
if (!await leser.VorhandenAsync(ct))
{
log.LogInformation(
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
return;
return new Serienstand(0, Angefasst: false);
}
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
@@ -343,7 +485,10 @@ public sealed class Markenerkennung(
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == serieId)
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
(f, d) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, d.Path, d.Duration })
(f, d) => new
{
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, d.Path, d.Duration,
})
.ToListAsync(ct);
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
@@ -353,10 +498,19 @@ public sealed class Markenerkennung(
.Where(x => x.SeriesId == serieId)
.ToListAsync(ct);
var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
serienname, staffeln.Count, folgen.Count);
var gefunden = 0;
foreach (var staffel in folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key))
var angefasst = false;
foreach (var staffel in staffeln)
{
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
string Name(int i) => Folgenname(
serienname, geordnet[i].SeasonNumber, geordnet[i].EpisodeNumber, geordnet[i].Title);
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
// springen — es liest immer von vorn. Also die ganze Spur je
@@ -369,6 +523,7 @@ public sealed class Markenerkennung(
|| lauf.Folgen != geordnet.Count)
.ToList();
if (fehlt.Count == 0) continue;
angefasst = true;
// **Der Tonabgleich braucht etwas zum Vergleichen.** Eine
// Staffel mit einer einzigen Folge — eine laufende Serie, von
@@ -378,12 +533,19 @@ public sealed class Markenerkennung(
var ganze = new Dictionary<Guid, IReadOnlyList<uint>>();
if (geordnet.Count >= 2)
{
foreach (var folge in geordnet)
for (var i = 0; i < geordnet.Count; i++)
{
var abdruck = await leser.LesenAsync(folge.Path, ct);
if (abdruck.Count > 0) ganze[folge.Id] = abdruck;
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
var abdruck = await leser.LesenAsync(geordnet[i].Path, ct);
if (abdruck.Count > 0) ganze[geordnet[i].Id] = abdruck;
}
}
else
{
log.LogInformation(
"„{Serie}“, Staffel {Staffel}: nur eine Folge — Tonabgleich entfällt, "
+ "es bleibt das Bild.", serienname, staffel.Key);
}
// Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg
// unten läuft trotzdem.
@@ -432,8 +594,11 @@ public sealed class Markenerkennung(
}
}
if (!fehlt.Contains(Markenart.Abspann)) continue;
var nummern = geordnet.Select(f => f.Id).ToList();
var ueberBild = new HashSet<Guid>();
if (fehlt.Contains(Markenart.Abspann))
{
// Auch wenn der Tonabgleich gar nicht erst lief: gesucht wurde,
// und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem.
await VermerkenAsync(db, serieId, staffel.Key, Markenart.Abspann,
@@ -454,30 +619,105 @@ public sealed class Markenerkennung(
// sonst sieht die Abfrage die eben geschriebenen Marken nicht.
await db.SaveChangesAsync(ct);
var nummern = geordnet.Select(f => f.Id).ToList();
var mitAbspann = await db.Marken.AsNoTracking()
.Where(m => m.Art == Markenart.Abspann
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => m.EpisodeId!.Value)
.ToListAsync(ct);
foreach (var folge in geordnet)
for (var i = 0; i < geordnet.Count; i++)
{
var folge = geordnet[i];
if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
folge.Path, dauer.TotalSeconds, ct))
Name(i), folge.Path, dauer.TotalSeconds, ct))
{
ueberBild.Add(folge.Id);
gefunden++;
}
}
}
await db.SaveChangesAsync(ct);
await StaffelMeldenAsync(db, serienname, staffel.Key, geordnet
.Select((f, i) => (f.Id, Name: Name(i))).ToList(), ueberBild, ct);
}
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
// und genau die verhindern den nächsten vergeblichen Lauf.
await db.SaveChangesAsync(ct);
log.LogInformation("Markenerkennung für {Serie}: {Anzahl} Marken.", serieId, gefunden);
if (angefasst)
{
log.LogInformation("„{Serie}“ fertig: {Anzahl} Marke(n).", serienname, gefunden);
}
else
{
log.LogInformation("„{Serie}“: nichts zu tun, alle Staffeln schon durchsucht.",
serienname);
}
return new Serienstand(gefunden, angefasst);
}
/// <summary>Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.</summary>
///
/// <remarks>
/// <b>Aus der Datenbank gelesen, nicht mitgezählt.</b> Beim Zählen
/// während des Laufs stünde im Protokoll, was dieser Lauf geschrieben
/// hat; hier steht, was danach wirklich an der Folge hängt. Nur das
/// beantwortet „warum kommt bei dieser Folge kein Knopf?".
/// </remarks>
private async Task StaffelMeldenAsync(
FlaroContext db, string serienname, int staffel,
IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet<Guid> ueberBild,
CancellationToken ct)
{
var nummern = folgen.Select(f => f.Id).ToList();
var marken = await db.Marken.AsNoTracking()
.Where(m => m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync(ct);
var mitIntro = marken.Count(m => m.Art == Markenart.Intro);
var mitAbspann = marken.Count(m => m.Art == Markenart.Abspann);
log.LogInformation(
"„{Serie}“, Staffel {Staffel}: Vorspann in {Intro} von {Folgen} Folge(n), "
+ "Abspann in {Abspann} von {Folgen}.",
serienname, staffel, mitIntro, folgen.Count, mitAbspann);
foreach (var (id, name) in folgen)
{
var intro = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Intro);
var abspann = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Abspann);
log.LogInformation("{Folge}: {Vorspann}, {Abspann}.",
name,
intro is null ? "kein Vorspann"
: $"Vorspann {Zeit(intro.VonSekunden)}–{Zeit(intro.BisSekunden)}",
abspann is null ? "kein Abspann"
: $"Abspann ab {Zeit(abspann.VonSekunden)}"
+ (ueberBild.Contains(id) ? " (über das Bild)" : ""));
}
}
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
private static string Zeit(double sekunden)
{
var spanne = TimeSpan.FromSeconds(Math.Max(0, sekunden));
return spanne.TotalHours >= 1 ? spanne.ToString(@"h\:mm\:ss") : spanne.ToString(@"m\:ss");
}
/// <summary>„Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.</summary>
private static string Folgenname(string serie, int staffel, int folge, string? titel)
{
var kopf = $"{serie} S{staffel:00}E{folge:00}";
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
}
/// <summary>Welcher Ausschnitt des Abdrucks abgetastet wird.</summary>
@@ -21,6 +21,15 @@ public enum Scanphase
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
Aufraeumen,
/// <summary>Vorspann und Abspann werden gesucht.</summary>
///
/// <remarks>
/// Derselbe Weg wie beim Scan, und aus demselben Grund: der Lauf geht
/// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
/// Verwaltung minutenlang so aus, als täte sich nichts.
/// </remarks>
Marken,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
///
/// <remarks>
@@ -70,6 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
Scanphase.Marken => $"Suche Vorspann und Abspann: {Was}{zaehler}",
Scanphase.Vermerk => Was,
_ => Was,
};
@@ -0,0 +1,55 @@
using Flaro.Core.Library;
namespace Flaro.Core.Tests;
/// <summary>Der Satz, der in der Verwaltung mitläuft.</summary>
///
/// <remarks>
/// <b>Warum das geprüft wird.</b> Die Markensuche läuft über Stunden und
/// hat bisher gar nichts gemeldet — in der Verwaltung stand das Ergebnis
/// des längst beendeten Scans, während der Server noch jede Folge
/// durchhörte. Sie benutzt jetzt denselben Weg wie der Scan, und der Satz
/// entsteht hier, nicht in der Oberfläche: die Apps brauchen denselben.
/// </remarks>
public class ScanschrittTests
{
/// <summary>Mit Zähler, wenn die Gesamtzahl bekannt ist.</summary>
[Test]
public void Die_Markensuche_nennt_Titel_und_Zaehler()
{
var schritt = new Scanschritt(
Scanphase.Marken, "Biohackers S02E01 · Neuanfang", 12, 95);
Assert.That(schritt.Text(),
Is.EqualTo("Suche Vorspann und Abspann: Biohackers S02E01 · Neuanfang — 12 von 95"));
}
/// <summary>Ohne Gesamtzahl kein Zähler — „3 von 0" wäre eine Falschauskunft.</summary>
[Test]
public void Ohne_Gesamtzahl_steht_nur_der_Titel()
{
var schritt = new Scanschritt(Scanphase.Marken, "Arrival (2016)");
Assert.That(schritt.Text(), Is.EqualTo("Suche Vorspann und Abspann: Arrival (2016)"));
}
/// <summary>Die vorhandenen Phasen bleiben, wie sie waren.</summary>
///
/// <remarks>
/// Eine neue Phase in der Mitte der Aufzählung hätte die gespeicherten
/// Zahlen verschoben — hier steht, dass die Sätze unverändert sind.
/// </remarks>
[Test]
public void Der_Scan_sagt_weiterhin_dasselbe()
{
Assert.Multiple(() =>
{
Assert.That(new Scanschritt(Scanphase.Pruefen, "a/b/c.mkv", 7, 9).Text(),
Is.EqualTo("Prüfe b/c.mkv — 7 von 9"));
Assert.That(new Scanschritt(Scanphase.Aufraeumen, "").Text(),
Is.EqualTo("Räume auf und lege Doppeltes zusammen"));
Assert.That(new Scanschritt(Scanphase.Vermerk, "Serie angelegt").Text(),
Is.EqualTo("Serie angelegt"));
});
}
}
+5
View File
@@ -196,6 +196,11 @@ export type Scanstand = {
missing: number | null;
/** Woran der Lauf gerade ist, in einem Satz. */
step: string | null;
/** Woran die Markensuche gerade ist — `null`, wenn keine läuft.
*
* **Ein eigenes Feld, nicht `step`.** Sie hängt sich hinter den Scan und
* läuft weiter, wenn der längst „fertig" meldet. */
marken?: string | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
+13 -2
View File
@@ -238,8 +238,10 @@ export function bibliotheksbereich(
+ 'werden noch einmal geprüft.',
bestaetigen: 'Suchen',
tun: async () => {
laufendSetzen(true);
await mediathekMarken(aktuell.id, true);
hinweisZeigen('Die Suche läuft. Das Protokoll von heute zeigt den Fortschritt.');
hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
void verfolgen(false);
},
});
@@ -310,6 +312,13 @@ export function bibliotheksbereich(
laufendSetzen(false);
stand.textContent = s.error ?? 'Gescheitert.';
return;
} else if (s.marken) {
// **Die Markensuche hängt sich hinten an und läuft weiter.**
// Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen
// Zweig stünde hier längst das Ergebnis des Scans, während der
// Server noch jede Folge durchhört.
laufendSetzen(true);
stand.textContent = s.marken;
}
// **Alle Zähler auf demselben Takt.**
@@ -325,7 +334,9 @@ export function bibliotheksbereich(
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
if (s.state === 'finished') {
// Ein fertiger Scan ist nicht das Ende, solange die Markensuche
// dahinter noch läuft — sonst fiele die Zeile mitten im Lauf weg.
if (s.state === 'finished' && !s.marken) {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
// was eine Nummer trägt, aber zu keiner Serie gehört.
+46 -4
View File
@@ -20,8 +20,14 @@ import { abschnitt } from './teile';
* ihm die Seite aus der Hand nehmen.
*/
/** Wie viele Zeilen anfangs geholt werden. */
const ANFANG = 300;
/** Wie viele Zeilen anfangs geholt werden.
*
* **Tausend statt dreihundert.** Seit die Markensuche je Folge eine Zeile
* schreibt, hat ein Tag, an dem sie lief, Tausende davon — mit
* dreihundert sah man vom übrigen Tag nichts mehr. Das Suchfeld unten
* macht die Menge dann wieder beherrschbar.
*/
const ANFANG = 1000;
/** Wie oft nachgesehen wird, solange die Seite offen ist. */
const TAKT = 4000;
@@ -30,6 +36,24 @@ export function protokollbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Protokoll'));
// **Ohne Suche ist ein langes Protokoll keine Auskunft.** An einem Tag,
// an dem die Markensuche lief, stehen hier Tausende Zeilen; die eine
// Serie darin von Hand zu finden hiesse scrollen, bis man aufgibt.
const suchreihe = document.createElement('div');
suchreihe.className = 'knopfreihe';
const suche = document.createElement('input');
suche.type = 'search';
suche.className = 'feld';
suche.placeholder = 'Im Protokoll suchen …';
suche.setAttribute('aria-label', 'Protokoll durchsuchen');
const treffer = document.createElement('span');
treffer.className = 'winzig';
suchreihe.append(suche, treffer);
bereich.appendChild(suchreihe);
const liste = document.createElement('div');
liste.className = 'protokollliste';
liste.innerHTML = '<div class="spinner"></div>';
@@ -97,18 +121,36 @@ export function protokollbereich(): HTMLElement {
}
}
// Beim Tippen neu zeichnen — die Zeilen liegen schon da, es wird nichts
// nachgeholt.
suche.oninput = () => zeichnen(zuletzt);
function zeichnen(roh: string): void {
const zeilenListe = roh.split('\n').filter(z => z.trim().length > 0);
const alle = roh.split('\n').filter(z => z.trim().length > 0);
const gesucht = suche.value.trim().toLowerCase();
const zeilenListe = gesucht
? alle.filter(z => z.toLowerCase().includes(gesucht))
: alle;
// **Die Trefferzahl gehört daneben.** Ein leeres Ergebnis ohne Zahl
// sieht aus wie ein kaputtes Protokoll statt wie „nichts gefunden".
treffer.textContent = gesucht
? `${zeilenListe.length} von ${alle.length} Zeilen`
: '';
// **Vor dem Neuzeichnen merken, ob man unten stand.** Danach ist die
// Höhe eine andere, und die Frage liesse sich nicht mehr beantworten.
const amEnde = liste.scrollHeight - liste.scrollTop - liste.clientHeight < 40;
liste.innerHTML = '';
if (zeilenListe.length === 0) {
if (alle.length === 0) {
liste.appendChild(leer('Heute noch nichts protokolliert.'));
return;
}
if (zeilenListe.length === 0) {
liste.appendChild(leer(`Keine Zeile enthält „${gesucht}".`));
return;
}
zeilenListe.forEach(z => liste.appendChild(protokollzeile(z)));
if (amEnde) liste.scrollTop = liste.scrollHeight;