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;
}
}