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();
}
+307 -67
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,52 +594,130 @@ public sealed class Markenerkennung(
}
}
if (!fehlt.Contains(Markenart.Abspann)) continue;
// 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,
geordnet.Count, ct);
// **Was der Ton nicht fand, sucht das Bild.**
//
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
// weiss davon nichts; dort sieht das Finale aus wie jede
// andere Folge.
//
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
// 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);
var ueberBild = new HashSet<Guid>();
foreach (var folge in geordnet)
if (fehlt.Contains(Markenart.Abspann))
{
if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
// 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,
geordnet.Count, ct);
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
folge.Path, dauer.TotalSeconds, ct))
// **Was der Ton nicht fand, sucht das Bild.**
//
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
// weiss davon nichts; dort sieht das Finale aus wie jede
// andere Folge.
//
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
// sonst sieht die Abfrage die eben geschriebenen Marken nicht.
await db.SaveChangesAsync(ct);
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);
for (var i = 0; i < geordnet.Count; i++)
{
gefunden++;
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,
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>