diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 9be3c93..1d6b379 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -322,7 +322,16 @@ public sealed record ScanStatus(
/// Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.
int? Missing = null,
/// Woran der Lauf gerade ist, in einem Satz.
- string? Step = null);
+ string? Step = null,
+ /// Woran die Markensuche gerade ist — null, wenn keine läuft.
+ ///
+ ///
+ /// Ein eigenes Feld, nicht . 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.
+ ///
+ string? Marken = null);
/// Was das Nachschlagen bei der Metadatenquelle ergeben hat.
public sealed record MetadataProgress(
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index cddb4df..21c71f0 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -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();
}
diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs
index cc0b75d..a934970 100644
--- a/server/src/Flaro.Api/Markenerkennung.cs
+++ b/server/src/Flaro.Api/Markenerkennung.cs
@@ -84,6 +84,26 @@ public sealed class Markenerkennung(
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
+ /// Woran der Lauf je Mediathek gerade ist — für die Verwaltung.
+ ///
+ ///
+ ///
+ /// Ein eigener Zustand, nicht der des Scans. Der
+ /// ScanCoordinator setzt seinen Lauf auf „fertig", sobald er die
+ /// Markenerkennung angestossen hat — in dasselbe Feld zu schreiben
+ /// liesse einen beendeten Scan wieder laufend aussehen.
+ ///
+ ///
+ /// Nur der letzte Schritt, wie bei ScanState.Schritt: eine
+ /// Liste über viertausend Folgen wäre ein Leck, und wer die
+ /// Vorgeschichte braucht, liest das Protokoll.
+ ///
+ ///
+ private readonly ConcurrentDictionary _stand = new();
+
+ public Scanschritt? Stand(Guid mediathek) =>
+ _stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
+
/// Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.
public bool Starten(Guid serieId, bool erneut = false)
{
@@ -131,32 +151,97 @@ public sealed class Markenerkennung(
var db = raum.ServiceProvider.GetRequiredService();
var leser = raum.ServiceProvider.GetRequiredService();
- 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 _);
+ }
+ }
+
+ /// Alle Serien einer Mediathek, eine nach der anderen.
+ 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);
}
/// Startet den Lauf für eine ganze Mediathek — im Hintergrund.
@@ -189,7 +274,8 @@ public sealed class Markenerkennung(
/// läuft dann ins Leere, und das kostet eine Abfrage.
///
private async Task FilmeAsync(
- FlaroContext db, Guid mediathek, bool erneut, CancellationToken ct)
+ FlaroContext db, string mediathekname, Guid mediathek, bool erneut,
+ CancellationToken ct, Action 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);
}
+ /// „Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.
+ private static string Filmname(string? titel, int? jahr)
+ {
+ var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
+ return jahr is { } j ? $"{name} ({j})" : name;
+ }
+
/// Sucht den Abspann eines Films — im Bild.
///
///
@@ -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.
///
///
+ ///
+ /// 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.
+ ///
private async Task 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)
+ /// Was ein Serienlauf ergeben hat — für die Zusammenfassung.
+ ///
+ /// Wie viele Marken geschrieben wurden.
+ ///
+ /// 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.
+ ///
+ private readonly record struct Serienstand(int Marken, bool Angefasst);
+
+ private async Task LaufenAsync(
+ Guid serieId, bool erneut, CancellationToken ct, Action? melden = null)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService();
var leser = raum.ServiceProvider.GetRequiredService();
var abtaster = raum.ServiceProvider.GetRequiredService();
+ 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>();
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();
- 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);
+ }
+
+ /// Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.
+ ///
+ ///
+ /// Aus der Datenbank gelesen, nicht mitgezählt. 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?".
+ ///
+ private async Task StaffelMeldenAsync(
+ FlaroContext db, string serienname, int staffel,
+ IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet 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)" : ""));
+ }
+ }
+
+ /// „41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.
+ 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");
+ }
+
+ /// „Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.
+ 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}";
}
/// Welcher Ausschnitt des Abdrucks abgetastet wird.
diff --git a/server/src/Flaro.Core/Library/Scanschritt.cs b/server/src/Flaro.Core/Library/Scanschritt.cs
index ad1845e..6a9b481 100644
--- a/server/src/Flaro.Core/Library/Scanschritt.cs
+++ b/server/src/Flaro.Core/Library/Scanschritt.cs
@@ -21,6 +21,15 @@ public enum Scanphase
/// Verwaistes wird entfernt, Doppeltes zusammengelegt.
Aufraeumen,
+ /// Vorspann und Abspann werden gesucht.
+ ///
+ ///
+ /// 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.
+ ///
+ Marken,
+
/// Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.
///
///
@@ -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,
};
diff --git a/server/tests/Flaro.Core.Tests/ScanschrittTests.cs b/server/tests/Flaro.Core.Tests/ScanschrittTests.cs
new file mode 100644
index 0000000..3c4427e
--- /dev/null
+++ b/server/tests/Flaro.Core.Tests/ScanschrittTests.cs
@@ -0,0 +1,55 @@
+using Flaro.Core.Library;
+
+namespace Flaro.Core.Tests;
+
+/// Der Satz, der in der Verwaltung mitläuft.
+///
+///
+/// Warum das geprüft wird. 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.
+///
+public class ScanschrittTests
+{
+ /// Mit Zähler, wenn die Gesamtzahl bekannt ist.
+ [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"));
+ }
+
+ /// Ohne Gesamtzahl kein Zähler — „3 von 0" wäre eine Falschauskunft.
+ [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)"));
+ }
+
+ /// Die vorhandenen Phasen bleiben, wie sie waren.
+ ///
+ ///
+ /// Eine neue Phase in der Mitte der Aufzählung hätte die gespeicherten
+ /// Zahlen verschoben — hier steht, dass die Sätze unverändert sind.
+ ///
+ [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"));
+ });
+ }
+}
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 1a0490a..8e9ad68 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -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;
diff --git a/web/app/src/views/einstellungen/mediatheken.ts b/web/app/src/views/einstellungen/mediatheken.ts
index 5101689..598da84 100644
--- a/web/app/src/views/einstellungen/mediatheken.ts
+++ b/web/app/src/views/einstellungen/mediatheken.ts
@@ -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.
diff --git a/web/app/src/views/einstellungen/protokolle.ts b/web/app/src/views/einstellungen/protokolle.ts
index e5247db..e9cc950 100644
--- a/web/app/src/views/einstellungen/protokolle.ts
+++ b/web/app/src/views/einstellungen/protokolle.ts
@@ -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 = '';
@@ -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;