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;