Files
Flaro/server/src/Flaro.Api/Segmenterkennung.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

989 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using Flaro.Core.Library;
using Flaro.Core.Media;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Wie weit ein Segmentlauf greift.</summary>
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat ein Segment" sind zwei verschiedene
/// Dinge</b>, 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.
/// </para>
/// <para>
/// Nach jeder Verbesserung an der Erkennung will man genau diese Titel
/// noch einmal — und eben nicht die vierhundert, bei denen längst etwas
/// steht.
/// </para>
/// </remarks>
public enum Segmentumfang
{
/// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary>
Neue,
/// <summary>
/// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon
/// ein Segment steht.
/// </summary>
Luecken,
/// <summary>Alles, auch was längst ein Segment hat.</summary>
Alles,
}
/// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
///
/// <remarks>
/// <para>
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b>
/// <see cref="Fingerprintabgleich"/> 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.
/// </para>
/// <para>
/// <b>Je Staffel, nicht je Serie.</b> Serien wechseln ihren Vorspann
/// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln
/// verglichen fände man entweder nichts oder das Falsche.
/// </para>
/// <para>
/// <b>Im Hintergrund, mit einem Zustand zum Nachsehen.</b> Eine Staffel
/// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein
/// Zeitlimit zu laufen und am Ende nichts zu haben.
/// </para>
/// </remarks>
public sealed class Segmenterkennung(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<Segmenterkennung> log)
{
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public const double Introfenster = 15 * 60;
/// <summary>Wie viel vom Ende nach einem Abspann abgesucht wird.</summary>
public const double Abspannfenster = 10 * 60;
/// <summary>Wie weit vom Ende eines Films nach dem Abspann gesucht wird.</summary>
///
/// <remarks>
/// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem
/// Dreistünder zu viel und bei einem Kurzfilm zu wenig.
/// </remarks>
public const double Suchbereich = 20 * 60;
/// <summary>Was ein gefundenes Stück höchstens lang sein darf.</summary>
///
/// <remarks>
/// <b>Die wichtigste Schranke.</b> Zwei Folgen, die dieselbe Datei
/// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne
/// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film.
/// </remarks>
public const double Hoechstlaenge = 5 * 60;
/// <summary>Dasselbe für den Abspann — und deutlich grosszügiger.</summary>
///
/// <remarks>
/// <b>Fünf Minuten sind für einen Abspann keine Obergrenze, sondern
/// die Regel.</b> 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.
/// </remarks>
public const double Hoechstabspann = Abspannfenster;
/// <summary>Welche Staffeln gerade abgetastet werden.</summary>
private readonly ConcurrentDictionary<Guid, bool> _laufend = new();
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
/// <summary>Woran der Lauf je Mediathek gerade ist — für die Verwaltung.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
/// Segmenterkennung angestossen hat — in dasselbe Feld zu schreiben
/// liesse einen beendeten Scan wieder laufend aussehen.
/// </para>
/// <para>
/// <b>Nur der letzte Schritt</b>, wie bei <c>ScanState.Schritt</c>: eine
/// Liste über viertausend Folgen wäre ein Leck, und wer die
/// Vorgeschichte braucht, liest das Protokoll.
/// </para>
/// </remarks>
private readonly ConcurrentDictionary<Guid, Scanschritt> _stand = new();
public Scanschritt? Stand(Guid mediathek) =>
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
public bool Starten(Guid serieId, 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;
}
/// <summary>Nach einem Scan: jede Serie der Mediathek, der noch Segmente fehlen.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier, weil es sonst nie passiert.</b> 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.
/// </para>
/// <para>
/// <b>Eine nach der anderen, nicht alle zugleich.</b> 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.
/// </para>
/// </remarks>
public async Task NachScanAsync(
Guid mediathek, Segmentumfang umfang = Segmentumfang.Neue,
CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
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 _);
}
}
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
private async Task SerienAsync(
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien,
Guid mediathek, 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);
}
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
///
/// <remarks>
/// <b>Der Knopf darf nicht warten.</b> Ü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.
/// </remarks>
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));
}
});
}
/// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private async Task FilmeAsync(
FlaroContext db, string mediathekname, Guid mediathek, Segmentumfang umfang,
CancellationToken ct, Action<Scanschritt> 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);
}
/// <summary>„Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.</summary>
private static string Filmname(string? titel, int? jahr)
{
var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
return jahr is { } j ? $"{name} ({j})" : name;
}
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum hier ein anderer Weg.</b> 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.
/// </para>
/// <para>
/// <b>Erst grob, dann zielen.</b> Ein Überblick über die letzten zwanzig
/// Minuten sagt, <i>ob</i> 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.
/// </para>
/// </remarks>
public async Task<bool> FilmAsync(
Guid filmId, Segmentumfang umfang, CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
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;
}
/// <summary>Sucht den Abspann im Bild — bei einem Film wie bei einer Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Dunkel und bis zum Ende durchhalten</b> — 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.
/// </para>
/// <para>
/// <b>Warum das auch bei Folgen gebraucht wird.</b> 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.
/// </para>
/// </remarks>
/// <param name="name">
/// Wie der Titel im Protokoll heissen soll — „Arrival (2016)" oder
/// „Biohackers S02E01". Diese Stelle kennt sonst nur den Dateipfad, und
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param>
private async Task<bool> BildabspannAsync(
FlaroContext db, 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);
}
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
///
/// <param name="Segmente">Wie viele Segmente geschrieben wurden.</param>
/// <param name="Angefasst">
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
/// erst gefragt, und beides in einen Topf zu werfen machte die
/// Zusammenfassung wertlos.
/// </param>
private readonly record struct Serienstand(int Segmente, bool Angefasst);
private async Task<Serienstand> LaufenAsync(
Guid serieId, Segmentumfang umfang, CancellationToken ct,
Action<Scanschritt>? melden = null)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
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<Guid, IReadOnlyList<uint>>();
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<uint> Fingerprint)>();
var fensteranfang = new Dictionary<Guid, double>();
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<Guid>();
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);
}
/// <summary>Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.</summary>
///
/// <remarks>
/// <b>Aus der Datenbank gelesen, nicht mitgezählt.</b> Beim Zählen
/// während des Laufs stünde im Protokoll, was dieser Lauf geschrieben
/// hat; hier steht, was danach wirklich an der Folge hängt. Nur das
/// beantwortet „warum kommt bei dieser Folge kein Knopf?".
/// </remarks>
private async Task StaffelMeldenAsync(
FlaroContext db, string serienname, int staffel,
IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet<Guid> ueberBild,
CancellationToken ct)
{
var nummern = folgen.Select(f => f.Id).ToList();
var 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)" : ""));
}
}
/// <summary>Was die Datei an Kapiteln mitbringt — in einem Halbsatz.</summary>
private static string Kapitellage(IReadOnlyList<Kapitel> 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}“.";
}
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
private static string Zeit(double sekunden)
{
var spanne = TimeSpan.FromSeconds(Math.Max(0, sekunden));
return spanne.TotalHours >= 1 ? spanne.ToString(@"h\:mm\:ss") : spanne.ToString(@"m\:ss");
}
/// <summary>„Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.</summary>
private static string Folgenname(string serie, int staffel, int folge, string? titel)
{
var kopf = $"{serie} S{staffel:00}E{folge:00}";
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
}
/// <summary>Welcher Ausschnitt des Fingerprints abgetastet wird.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Hält fest, dass bei diesem Film gesucht wurde.</summary>
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(),
});
}
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
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<bool> 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);
/// <summary>Setzt die Segmente einer Art auf genau diese Stücke.</summary>
///
/// <remarks>
/// <para>
/// <b>Ersetzen, nicht umschreiben.</b> 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.
/// </para>
/// <para>
/// <b>In derselben Transaktion</b> — 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.
/// </para>
/// </remarks>
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Segmentart art,
IReadOnlyList<Abspannstück> 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;
}
}