using System.Collections.Concurrent; using Flaro.Core.Library; using Flaro.Core.Media; using Flaro.Data; using Flaro.Media; using Microsoft.EntityFrameworkCore; namespace Flaro.Api; /// Wie weit ein Segmentlauf greift. /// /// /// /// „Schon durchsucht" und „hat ein Segment" sind zwei verschiedene /// Dinge, und genau dazwischen fehlte bisher die Unterscheidung. Ein /// Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und /// kein Segment: der nächste Lauf übersprang ihn, obwohl er der /// interessanteste Fall ist. /// /// /// Nach jeder Verbesserung an der Erkennung will man genau diese Titel /// noch einmal — und eben nicht die vierhundert, bei denen längst etwas /// steht. /// /// public enum Segmentumfang { /// Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan. Neue, /// /// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon /// ein Segment steht. /// Luecken, /// Alles, auch was längst ein Segment hat. Alles, } /// Sucht Vorspann und Abspann einer Staffel. /// /// /// /// Beides mit derselben Rechnung, nur in einem anderen Fenster. /// sucht das längste Stück, das zwei Tonabdrücke /// teilen — was dabei herauskommt, entscheidet allein, welchen Ausschnitt /// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann. /// Eine zweite Erkennung dafür zu schreiben hiesse, dieselbe Suche zweimal /// zu haben. /// /// /// Je Staffel, nicht je Serie. Serien wechseln ihren Vorspann /// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln /// verglichen fände man entweder nichts oder das Falsche. /// /// /// Im Hintergrund, mit einem Zustand zum Nachsehen. Eine Staffel /// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein /// Zeitlimit zu laufen und am Ende nichts zu haben. /// /// public sealed class Segmenterkennung( IServiceScopeFactory scopes, TimeProvider clock, ILogger log) { /// Wie viel vom Anfang nach einem Vorspann abgesucht wird. /// /// /// Fünfzehn Minuten. Ein Vorspann liegt fast immer in den ersten fünf; /// manche Serie stellt aber eine Viertelstunde Handlung davor. Weiter /// zu suchen kostet Zeit und schafft vor allem Gelegenheiten, zwei /// zufällig ähnliche Takte für einen Vorspann zu halten. /// public const double Introfenster = 15 * 60; /// Wie viel vom Ende nach einem Abspann abgesucht wird. public const double Abspannfenster = 10 * 60; /// Wie weit vom Ende eines Films nach dem Abspann gesucht wird. /// /// /// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem /// Dreistünder zu viel und bei einem Kurzfilm zu wenig. /// public const double Suchbereich = 20 * 60; /// Was ein gefundenes Stück höchstens lang sein darf. /// /// /// Die wichtigste Schranke. Zwei Folgen, die dieselbe Datei /// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne /// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film. /// public const double Hoechstlaenge = 5 * 60; /// Dasselbe für den Abspann — und deutlich grosszügiger. /// /// /// Fünf Minuten sind für einen Abspann keine Obergrenze, sondern /// die Regel. Eine deutsche Serienproduktion listet Sprecher, /// Synchronregie und Studio hinterher; sechs Minuten kommen vor. Mit /// derselben Schranke wie beim Vorspann wurde der Fund gemacht und /// gleich wieder weggeworfen — gefunden, verworfen, und am Ende stand /// kein Knopf da, obwohl die Erkennung gearbeitet hatte. /// /// Weiter als das Suchfenster kann ein Segment ohnehin nicht reichen; /// mehr als dieses Fenster zu erlauben hiesse nichts zu erlauben. /// public const double Hoechstabspann = Abspannfenster; /// Welche Staffeln gerade abgetastet werden. private readonly ConcurrentDictionary _laufend = new(); 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 /// Segmenterkennung 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, Segmentumfang umfang = Segmentumfang.Neue) { if (!_laufend.TryAdd(serieId, true)) return false; _ = Task.Run(async () => { try { await LaufenAsync(serieId, umfang, CancellationToken.None); } catch (Exception fehler) { log.LogWarning(fehler, "Segmentsuche für {Serie} abgebrochen: {Grund}", serieId, Fehlertext.FuerMenschen(fehler)); } finally { _laufend.TryRemove(serieId, out _); } }); return true; } /// Nach einem Scan: jede Serie der Mediathek, der noch Segmente fehlen. /// /// /// /// Hier, weil es sonst nie passiert. Eine neue Folge bekommt /// kein Segment, wenn niemand danach fragt — und niemand fragt, weil man /// nicht weiss, dass es etwas zu fragen gibt. Der Scan ist der /// Augenblick, in dem neue Folgen entstehen. /// /// /// Eine nach der anderen, nicht alle zugleich. Jede liest von /// jeder ihrer Folgen ein Stück Ton; zwanzig Serien gleichzeitig hiesse /// zwanzig Leseköpfe auf derselben Platte. Beim ersten Mal über einen /// grossen Bestand dauert das entsprechend — danach findet der Lauf /// nichts mehr zu tun und ist in Sekunden vorbei. /// /// public async Task NachScanAsync( Guid mediathek, Segmentumfang umfang = Segmentumfang.Neue, CancellationToken ct = default) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var leser = raum.ServiceProvider.GetRequiredService(); 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 => new { x.Id, x.Title }) .OrderBy(x => x.Title) .ToListAsync(ct); 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, umfang, 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, umfang, 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, Segmentumfang umfang, CancellationToken ct) { var folgen = await db.Episodes.AsNoTracking() .CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct); log.LogInformation( "Segmentsuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). " + "Das kann Stunden dauern.", mediathekname, serien.Count, folgen); var segmente = 0; var ohneFund = 0; var angefasst = 0; for (var i = 0; i < serien.Count; i++) { if (ct.IsCancellationRequested) return; 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.Segmente, titel)); if (!_laufend.TryAdd(id, true)) continue; try { var stand = await LaufenAsync(id, umfang, ct, Melden); segmente += stand.Segmente; if (!stand.Angefasst) continue; angefasst++; if (stand.Segmente == 0) ohneFund++; } catch (Exception fehler) { // **Mit dem innersten Text.** Das Tagebuch hängt nur // `Message` an, und bei EF Core steht dort „See the inner // exception for details." — eine Zeile, die ausdrücklich // sagt, dass sie die Auskunft nicht enthält. log.LogWarning(fehler, "Segmentsuche für „{Serie}“ abgebrochen: {Grund}", titel, Fehlertext.FuerMenschen(fehler)); } finally { _laufend.TryRemove(id, out _); } } log.LogInformation( "Segmentsuche in „{Mediathek}“ durch: {Segmente} Segment(n) in {Angefasst} von " + "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.", mediathekname, segmente, angefasst, serien.Count, ohneFund); } /// Startet den Lauf für eine ganze Mediathek — im Hintergrund. /// /// /// Der Knopf darf nicht warten. Über fünfundneunzig Serien /// dauert das Stunden; eine Anfrage so lange offen zu halten hiesse, /// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im /// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine /// Segment steht. /// public void MediathekStarten(Guid mediathek, Segmentumfang umfang) { _ = Task.Run(async () => { try { await NachScanAsync(mediathek, umfang, CancellationToken.None); } catch (Exception fehler) { log.LogWarning(fehler, "Segmentlauf für Mediathek {Kennung} abgebrochen: {Grund}", mediathek, Fehlertext.FuerMenschen(fehler)); } }); } /// Die Filme einer Mediathek, denen noch ein Abspann fehlt. /// /// /// Getrennt von den Serien, weil der Weg ein anderer ist — und weil /// eine Mediathek immer nur eines von beidem enthält. Die Schleife /// läuft dann ins Leere, und das kostet eine Abfrage. /// private async Task FilmeAsync( FlaroContext db, string mediathekname, Guid mediathek, Segmentumfang umfang, CancellationToken ct, Action melden) { // **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem // `Where` mit einem Aufzählungswert übersetzt EF Core zwar meist, // aber „meist" ist hier kein Argument: so ist sicher, was in SQL // ankommt. var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek); abfrage = umfang switch { Segmentumfang.Alles => abfrage, // Die Lücke: gesucht wurde vielleicht, gefunden nichts. Segmentumfang.Luecken => abfrage.Where(m => !db.Segmente.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)), _ => abfrage.Where(m => !db.Segmentsuchen.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)), }; var offen = await abfrage .Select(m => new { m.Id, m.Title, m.Year }) .OrderBy(m => m.Title) .ToListAsync(ct); if (offen.Count == 0) return; log.LogInformation("Abspannsuche in „{Mediathek}“: {Anzahl} Film(e).", mediathekname, offen.Count); var gefunden = 0; for (var i = 0; i < offen.Count; i++) { if (ct.IsCancellationRequested) return; var film = offen[i]; var titel = Filmname(film.Title, film.Year); melden(new Scanschritt(Scanphase.Segmente, titel, i + 1, offen.Count)); try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; } catch (Exception fehler) { log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}", titel, Fehlertext.FuerMenschen(fehler)); } } 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. /// /// /// /// Warum hier ein anderer Weg. Bei einer Folge verrät sich der /// Abspann dadurch, dass er in jeder Folge derselbe ist. Ein Film hat /// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau /// einmal vor. Was bleibt, ist, dass er dunkel ist und bis zum Ende /// durchhält. /// /// /// Erst grob, dann zielen. Ein Überblick über die letzten zwanzig /// Minuten sagt, ob es eine dunkle Strecke gibt; er kommt aus /// Keyframesn und ist deshalb billig, aber auf fünf bis zehn /// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden. /// /// public async Task FilmAsync( Guid filmId, Segmentumfang umfang, CancellationToken ct = default) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var abtaster = raum.ServiceProvider.GetRequiredService(); var kapitelleser = raum.ServiceProvider.GetRequiredService(); 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, m.Title, m.Year, d.Path, d.Duration }) .FirstOrDefaultAsync(ct); // **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 (umfang == Segmentumfang.Luecken && await db.Segmente.AsNoTracking() .AnyAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct)) { log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel); return false; } if (umfang == Segmentumfang.Neue && await db.Segmentsuchen.AsNoTracking() .AnyAsync(x => x.MovieId == filmId && x.Art == Segmentart.Abspann, ct)) { log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel); return false; } await VermerkenAsync(db, filmId, Segmentart.Abspann, ct); var gefunden = await BildabspannAsync( db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path, laufzeit.TotalSeconds, ct); if (!gefunden) { // Nichts gefunden ist ein Ergebnis. Eine altes Segment, die jetzt // nicht mehr bestätigt wird, gehört weg — sonst bliebe ein // Fehlgriff für immer stehen. var alt = await db.Segmente .FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct); if (alt is not null) db.Segmente.Remove(alt); } await db.SaveChangesAsync(ct); return gefunden; } /// Sucht den Abspann im Bild — bei einem Film wie bei einer Folge. /// /// /// /// Dunkel und bis zum Ende durchhalten — mehr braucht es nicht, /// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober /// Überblick über die letzten zwanzig Minuten aus Keyframesn /// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll /// kurzer Sonden für die Kante. /// /// /// Warum das auch bei Folgen gebraucht wird. Der Fingerprintabgleich lebt /// davon, dass der Abspann in jeder Folge derselbe ist — und genau das /// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik, /// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus /// 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, Frameabtaster abtaster, Kapitelleser kapitelleser, Guid titelId, bool istFilm, string name, string pfad, double laufzeit, CancellationToken ct) { var regeln = Abspannregeln.Vorgabe; // **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet // Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit // zwanzig Minuten Keyframes. Und es findet, woran die // Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei // „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff // Surprise". var kapitel = await kapitelleser.LesenAsync(pfad, ct); if (Abspannkapitel.Suchen(kapitel, laufzeit, regeln) is { } ausKapitel) { log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.", name, TimeSpan.FromSeconds(ausKapitel)); return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann, ausKapitel, laufzeit, ct); } var ab = Math.Max(0, laufzeit - Suchbereich); var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct); // **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt" // allein ist keine Auskunft: ob die Datei zu wenige Keyframes // hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel // steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht. var stücke = Abspannkante.Suchen(proben, regeln, out var grund); if (stücke.Count == 0) { // **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat // oder nur namenlose, entscheidet, ob sich an diesem Weg // weiterbauen lohnt — und das sieht man nur an vielen Dateien. log.LogInformation( "„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), " + "letzte Probe {Letzte:0.#} % dunkel (nötig: {Noetig} %). {Kapitel}", name, grund, proben.Count, proben.Count > 0 ? proben[^1].Dunkelanteil : double.NaN, regeln.Mindestanteil, Kapitellage(kapitel)); return false; } // **Nur die erste Kante wird nachgemessen.** Sie ist die, die der // Zuschauer zuerst sieht, und die, an der „gilt als gesehen" hängt. // Die übrigen Grenzen liegen an Keyframesn und sind damit auf // fünf bis zehn Sekunden genau — dafür lohnt keine Sonde. var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab; var kante = await Abspannkante.VerfeinernAsync( hell, stücke[0].Von, regeln.Mindestanteil, wo => abtaster.DunkelanteilAsync(pfad, wo, ct)); var fein = stücke.ToList(); fein[0] = fein[0] with { Von = kante }; if (fein.Count == 1) { log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.", name, TimeSpan.FromSeconds(kante)); } else { log.LogInformation( "„{Titel}“: Abspann im Bild in {Stücke} Stücken ab {Stelle:h\\:mm\\:ss} " + "— dazwischen {Lücken}.", name, fein.Count, TimeSpan.FromSeconds(kante), string.Join(", ", fein.Zip(fein.Skip(1), (a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}"))); } return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann, fein, ct); } /// Was ein Serienlauf ergeben hat — für die Zusammenfassung. /// /// Wie viele Segmente 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 Segmente, bool Angefasst); private async Task LaufenAsync( Guid serieId, Segmentumfang umfang, 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 kapitelleser = 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 new Serienstand(0, Angefasst: false); } // Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den // Abgleich unbrauchbar: ohne Datei nichts zu lesen, ohne Laufzeit // kein Fenster fürs Ende. 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, f.Title, d.Path, d.Duration, }) .ToListAsync(ct); // **Was schon einmal gesucht wurde, und mit wie vielen Folgen.** // Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst // bei jedem Scan aus wie eine, in der noch niemand nachgesehen hat. var gesucht = await db.Segmentsuchen.AsNoTracking() .Where(x => x.SeriesId == serieId) .ToListAsync(ct); // **Und was tatsächlich an den Folgen hängt.** Für „nur die // Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde, // nicht ob etwas herauskam. var vorhanden = await db.Segmente.AsNoTracking() .Where(m => m.EpisodeId != null && db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId)) .Select(m => new { Folge = m.EpisodeId!.Value, m.Art }) .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); // Getrennt gezählt: „14 Segmente" beantwortet nicht, ob diese Serie // ihre Vorspänne bekommen hat — und das ist die häufigere Frage. var gefundenIntro = 0; var gefundenAbspann = 0; var bildfunde = 0; 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 // Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte // aus demselben Fingerprint. Das ist nicht nur richtig, sondern // billiger als zweimal lesen. // Eine Art fehlt dieser Staffel, sobald irgendeine ihrer Folgen // sie nicht hat. Der Fingerprintabgleich ist Sache der ganzen Staffel, // nicht der einzelnen Folge — für eine Lücke läuft sie ganz. bool Luecke(Segmentart art) => geordnet.Exists( f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art)); var fehlt = new[] { Segmentart.Intro, Segmentart.Abspann } .Where(art => umfang == Segmentumfang.Alles || gesucht.FirstOrDefault( x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf || lauf.Folgen != geordnet.Count || (umfang == Segmentumfang.Luecken && Luecke(art))) .ToList(); if (fehlt.Count == 0) continue; angefasst = true; // **Der Fingerprintabgleich braucht etwas zum Vergleichen.** Eine // Staffel mit einer einzigen Folge — eine laufende Serie, von // der erst die Premiere da ist — hat das nicht. Früher wurde // sie hier übersprungen, und zwar ganz; seit es den Bildweg // gibt, ist das zu früh: der braucht keine zweite Folge. var ganze = new Dictionary>(); if (geordnet.Count >= 2) { for (var i = 0; i < geordnet.Count; i++) { melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i))); var fingerprint = await leser.LesenAsync(geordnet[i].Path, ct); if (fingerprint.Count > 0) ganze[geordnet[i].Id] = fingerprint; } } else { log.LogInformation( "„{Serie}“, Staffel {Staffel}: nur eine Folge — Fingerprintabgleich entfällt, " + "es bleibt das Bild.", serienname, staffel.Key); } // Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg // unten läuft trotzdem. var tonarten = ganze.Count >= 2 ? fehlt : []; foreach (var art in tonarten) { var fingerprints = new List<(Guid Schlüssel, IReadOnlyList Fingerprint)>(); var fensteranfang = new Dictionary(); foreach (var folge in geordnet) { if (!ganze.TryGetValue(folge.Id, out var ganz)) continue; var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count); if (anzahl < 2) continue; fingerprints.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList())); fensteranfang[folge.Id] = vonIndex * Fingerprintleser.SekundenJeFingerprint; } if (fingerprints.Count < 2) continue; var treffer = Fingerprintabgleich.ReiheAbgleichen( fingerprints, Fingerprintleser.SekundenJeFingerprint); // **Vermerken, dass gesucht wurde — vor dem Ergebnis.** // Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu // vermerken hiesse, ihn beim nächsten Scan zu wiederholen. await VermerkenAsync(db, serieId, staffel.Key, art, geordnet.Count, ct); var grenze = art == Segmentart.Intro ? Hoechstlaenge : Hoechstabspann; foreach (var (folgenId, stelle) in treffer) { if (stelle.Bis - stelle.Von > grenze) continue; // **Die Zeiten liegen im Fenster, nicht in der Folge.** // Beim Abspann fängt das Fenster zehn Minuten vor dem // Ende an; ohne diesen Aufschlag stünde der Knopf bei // Minute zwei. var versatz = fensteranfang.GetValueOrDefault(folgenId); if (!await SchreibenAsync(db, folgenId, istFilm: false, art, stelle.Von + versatz, stelle.Bis + versatz, ct)) continue; if (art == Segmentart.Intro) gefundenIntro++; else gefundenAbspann++; } } var nummern = geordnet.Select(f => f.Id).ToList(); var ueberBild = new HashSet(); if (fehlt.Contains(Segmentart.Abspann)) { // Auch wenn der Fingerprintabgleich gar nicht erst lief: gesucht wurde, // und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem. await VermerkenAsync(db, serieId, staffel.Key, Segmentart.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 Keyframes; 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 Segmente nicht. await db.SaveChangesAsync(ct); var mitAbspann = await db.Segmente.AsNoTracking() .Where(m => m.Art == Segmentart.Abspann && m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value)) .Select(m => m.EpisodeId!.Value) .ToListAsync(ct); 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.Segmente, Name(i))); if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id, istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct)) { ueberBild.Add(folge.Id); gefundenAbspann++; bildfunde++; } } } 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); if (angefasst) { log.LogInformation( "„{Serie}“ fertig: {Intro} Vorspann, {Abspann} Abspann{Bild}.", serienname, gefundenIntro, gefundenAbspann, bildfunde > 0 ? $", davon {bildfunde} über das Bild" : ""); } else { log.LogInformation("„{Serie}“: nichts zu tun, alle Staffeln schon durchsucht.", serienname); } return new Serienstand(gefundenIntro + gefundenAbspann, 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 segmente = await db.Segmente.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 = segmente.Count(m => m.Art == Segmentart.Intro); var mitAbspann = segmente.Count(m => m.Art == Segmentart.Abspann); log.LogInformation( // **Jeder Platzhalter verbraucht ein Argument, auch ein // wiederholter.** Hier stand `{Folgen}` zweimal und wurde // einmal bedient; der Logger warf eine Ausnahme, und die riss // den ganzen Serienlauf mit — gefunden war dann alles umsonst. // CA2017 meldet das jetzt als Fehler, siehe Directory.Build.props. "„{Serie}“, Staffel {Staffel}: {Intro} Vorspann und {Abspann} Abspann " + "in {Folgen} Folge(n).", serienname, staffel, mitIntro, mitAbspann, folgen.Count); foreach (var (id, name) in folgen) { var intro = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Intro); var abspann = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.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)" : "")); } } /// Was die Datei an Kapiteln mitbringt — in einem Halbsatz. private static string Kapitellage(IReadOnlyList kapitel) { if (kapitel.Count == 0) return "Keine Kapitel."; var benannt = kapitel.Count(k => !string.IsNullOrWhiteSpace(k.Titel)); return benannt == 0 ? $"{kapitel.Count} Kapitel, alle ohne Namen." : $"{kapitel.Count} Kapitel, letztes „{kapitel[^1].Titel}“."; } /// „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 Fingerprints abgetastet wird. /// /// /// In Werten, nicht in Sekunden: der Fingerprint ist eine Reihe, und /// geschnitten wird an einer Stelle darin. Die Laufzeit steht nicht mehr /// in der Rechnung — die Länge des Fingerprints ist sie. /// private static (int Von, int Anzahl) Ausschnitt(Segmentart art, int werte) { var fenster = (int)Math.Round( (art == Segmentart.Intro ? Introfenster : Abspannfenster) / Fingerprintleser.SekundenJeFingerprint); if (fenster >= werte) return (0, werte); return art == Segmentart.Intro ? (0, fenster) : (werte - fenster, fenster); } /// Hält fest, dass bei diesem Film gesucht wurde. private async Task VermerkenAsync( FlaroContext db, Guid filmId, Segmentart art, CancellationToken ct) { var alt = db.Segmentsuchen.Local .FirstOrDefault(x => x.MovieId == filmId && x.Art == art) ?? await db.Segmentsuchen .FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct); if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; } db.Segmentsuchen.Add(new Segmentsuche { MovieId = filmId, Art = art, Folgen = 1, GelaufenAm = clock.GetUtcNow(), }); } /// Hält fest, dass in dieser Staffel gesucht wurde. private async Task VermerkenAsync( FlaroContext db, Guid serieId, int staffel, Segmentart art, int folgen, CancellationToken ct) { // **Erst im Änderungsspeicher nachsehen, dann in der Datenbank.** // // Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt // und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird // für dieselbe Staffel zweimal vermerkt — einmal nach dem // Fingerprintabgleich, einmal vor dem Bild —, und die zweite Abfrage fand // nichts, legte eine zweite Zeile an und brach beim Speichern am // eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in // diesem Lauf gefunden worden war. var alt = db.Segmentsuchen.Local .FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art) ?? await db.Segmentsuchen .FirstOrDefaultAsync( x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct); if (alt is not null) { alt.Folgen = folgen; alt.GelaufenAm = clock.GetUtcNow(); return; } db.Segmentsuchen.Add(new Segmentsuche { SeriesId = serieId, Staffel = staffel, Art = art, Folgen = folgen, GelaufenAm = clock.GetUtcNow(), }); } private Task SchreibenAsync( FlaroContext db, Guid titelId, bool istFilm, Segmentart art, double von, double bis, CancellationToken ct) => SchreibenAsync(db, titelId, istFilm, art, [new Abspannstück(von, bis)], ct); /// Setzt die Segmente einer Art auf genau diese Stücke. /// /// /// /// Ersetzen, nicht umschreiben. Hier stand ein Abgleich auf /// genau eine Zeile je Titel und Art — das ging, solange ein Abspann /// eine zusammenhängende Strecke war. Seit eine Post-Credit-Szene ihn /// teilen darf, sind es ein bis zwei Zeilen, und beim nächsten Lauf /// vielleicht wieder eine. Die alten zu löschen und die neuen /// anzulegen ist der einzige Weg, der in beide Richtungen stimmt. /// /// /// In derselben Transaktion — gespeichert wird weiter oben, und /// bis dahin stehen Löschung und Anlage zusammen. Dazwischen gibt es /// keinen Augenblick, in dem der Titel gar kein Segment hätte. /// /// private async Task SchreibenAsync( FlaroContext db, Guid titelId, bool istFilm, Segmentart art, IReadOnlyList stücke, CancellationToken ct) { if (stücke.Count == 0) return false; var alte = istFilm ? await db.Segmente.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct) : await db.Segmente.Where(m => m.EpisodeId == titelId && m.Art == art).ToListAsync(ct); db.Segmente.RemoveRange(alte); foreach (var stück in stücke) { db.Segmente.Add(new Segment { EpisodeId = istFilm ? null : titelId, MovieId = istFilm ? titelId : null, Art = art, VonSekunden = stück.Von, BisSekunden = stück.Bis, ErkanntAm = clock.GetUtcNow(), }); } return true; } }