„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>
989 lines
43 KiB
C#
989 lines
43 KiB
C#
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;
|
||
}
|
||
}
|