Vier Sekunden, die ffprobe zu früh aufhörte zu lesen
Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.
Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.
Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:
Wunsch Suche (0,5 s) ffmpeg Abweichung
52,0 48 52,0 −4,0
73,3 68 72,0 −4,0
100,2 96 100,0 −4,0
Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.
Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.
Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8112c15d4d
commit
7673c73757
@@ -475,7 +475,16 @@ public sealed record MovieResponse(
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null);
|
||||
string? HeroImage = null,
|
||||
|
||||
/// <summary>Der Abspann, sofern er im Bild gefunden wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er
|
||||
/// daran, dass er sich über die Folgen einer Staffel wiederholt, und
|
||||
/// ein Film hat nichts, womit er sich vergleichen liesse.
|
||||
/// </remarks>
|
||||
IReadOnlyList<MarkerResponse>? Markers = null);
|
||||
|
||||
public sealed record CreditResponse(
|
||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||
|
||||
@@ -151,12 +151,37 @@ public static class LibraryContentEndpoints
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Marken.AsNoTracking()
|
||||
.Where(m => folgen.Contains(m.EpisodeId))
|
||||
.Select(m => new { m.EpisodeId, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
|
||||
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.EpisodeId,
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
|
||||
/// <summary>Dasselbe für Filme.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Getrennt und nicht über eine gemeinsame Kennung: eine Marke gehört
|
||||
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
|
||||
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
|
||||
/// mit derselben — unmöglich heute, aber nichts hält es fest.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MarkerResponse>> FilmmarkenZu(
|
||||
FlaroContext db, IReadOnlyCollection<Guid> filme)
|
||||
{
|
||||
if (filme.Count == 0)
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Marken.AsNoTracking()
|
||||
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
|
||||
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
@@ -549,6 +574,7 @@ public static class LibraryContentEndpoints
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||
var marken = await FilmmarkenZu(db, ids);
|
||||
|
||||
var items = kennungen.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
@@ -561,7 +587,8 @@ public static class LibraryContentEndpoints
|
||||
TitlePinned: m.TitlePinned,
|
||||
SortTitle: m.SortTitleValue,
|
||||
SortTitlePinned: m.SortTitlePinned,
|
||||
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)))
|
||||
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
|
||||
Markers: marken[m.Id].ToList()))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||
@@ -756,7 +783,8 @@ public static class LibraryContentEndpoints
|
||||
PublishAt: film.PublishAt,
|
||||
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
|
||||
LibraryId: film.LibraryId,
|
||||
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
|
||||
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
|
||||
Markers: (await FilmmarkenZu(db, [id]))[id].ToList()));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||
@@ -852,6 +880,29 @@ public static class LibraryContentEndpoints
|
||||
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
|
||||
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
|
||||
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
|
||||
// Dasselbe für einen Film — dort sucht der Server im Bild.
|
||||
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
|
||||
{
|
||||
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
_ = erkennung.FilmAsync(id, erneut);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Eine Marke wieder wegnehmen.**
|
||||
//
|
||||
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
|
||||
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
|
||||
// dagegen tun ausser die Datenbank anzufassen.
|
||||
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
|
||||
{
|
||||
var weg = await db.Marken.Where(m => m.MovieId == id).ExecuteDeleteAsync();
|
||||
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
|
||||
// legte der nächste Scan dieselbe falsche Marke wieder an.
|
||||
return weg > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPost("/api/v1/series/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
|
||||
{
|
||||
|
||||
@@ -105,6 +105,11 @@ public static class PlaybackEndpoints
|
||||
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann)
|
||||
.Select(m => (double?)m.VonSekunden)
|
||||
.FirstOrDefaultAsync()
|
||||
: zustand.MovieId is { } film
|
||||
? await db.Marken.AsNoTracking()
|
||||
.Where(m => m.MovieId == film && m.Art == Markenart.Abspann)
|
||||
.Select(m => (double?)m.VonSekunden)
|
||||
.FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
var ergebnis = ProgressRule.Apply(
|
||||
|
||||
@@ -47,6 +47,14 @@ public sealed class Markenerkennung(
|
||||
/// <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>
|
||||
@@ -130,9 +138,112 @@ public sealed class Markenerkennung(
|
||||
finally { _laufend.TryRemove(serie, out _); }
|
||||
}
|
||||
|
||||
await FilmeAsync(db, mediathek, ct);
|
||||
|
||||
log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek);
|
||||
}
|
||||
|
||||
/// <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, Guid mediathek, CancellationToken ct)
|
||||
{
|
||||
var offen = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.LibraryId == mediathek)
|
||||
.Where(m => !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync(ct);
|
||||
if (offen.Count == 0) return;
|
||||
|
||||
log.LogInformation("Suche den Abspann in {Anzahl} Filmen.", offen.Count);
|
||||
|
||||
var gefunden = 0;
|
||||
foreach (var film in offen)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
try { if (await FilmAsync(film, erneut: false, ct)) gefunden++; }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film);
|
||||
}
|
||||
}
|
||||
|
||||
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Filmen gefunden.",
|
||||
gefunden, offen.Count);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// Schlüsselbildern 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, bool erneut, CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == filmId)
|
||||
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
||||
(m, d) => new { m.Id, d.Path, d.Duration })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Ohne Datei nichts abzutasten, ohne Laufzeit kein Ende zu finden.
|
||||
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) return false;
|
||||
|
||||
if (!erneut && await db.Markensuchen.AsNoTracking()
|
||||
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
|
||||
return false;
|
||||
|
||||
var ab = Math.Max(0, laufzeit.TotalSeconds - Suchbereich);
|
||||
var proben = await abtaster.UeberblickAsync(
|
||||
film.Path, ab, laufzeit.TotalSeconds - ab, ct);
|
||||
|
||||
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
|
||||
|
||||
var regeln = Abspannregeln.Vorgabe;
|
||||
var grob = Abspannkante.Suchen(proben, regeln);
|
||||
|
||||
if (grob is { } stelle)
|
||||
{
|
||||
var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab;
|
||||
var kante = await Abspannkante.VerfeinernAsync(
|
||||
hell, stelle, regeln, wo => abtaster.HelligkeitAsync(film.Path, wo, ct));
|
||||
|
||||
await SchreibenAsync(db, filmId, istFilm: true, Markenart.Abspann,
|
||||
kante, laufzeit.TotalSeconds, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nichts gefunden ist ein Ergebnis. Eine alte Marke, die jetzt
|
||||
// nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
|
||||
// Fehlgriff für immer stehen.
|
||||
var alt = await db.Marken
|
||||
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct);
|
||||
if (alt is not null) db.Marken.Remove(alt);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return grob is not null;
|
||||
}
|
||||
|
||||
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
@@ -223,7 +334,7 @@ public sealed class Markenerkennung(
|
||||
// Ende an; ohne diesen Aufschlag stünde der Knopf bei
|
||||
// Minute zwei.
|
||||
var versatz = fensteranfang.GetValueOrDefault(folgenId);
|
||||
if (await SchreibenAsync(db, folgenId, art,
|
||||
if (await SchreibenAsync(db, folgenId, istFilm: false, art,
|
||||
stelle.Von + versatz, stelle.Bis + versatz, ct))
|
||||
gefunden++;
|
||||
}
|
||||
@@ -253,6 +364,24 @@ public sealed class Markenerkennung(
|
||||
return art == Markenart.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, Markenart art, CancellationToken ct)
|
||||
{
|
||||
var alt = await db.Markensuchen
|
||||
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
|
||||
|
||||
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
|
||||
|
||||
db.Markensuchen.Add(new Markensuche
|
||||
{
|
||||
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, Markenart art, int folgen, CancellationToken ct)
|
||||
@@ -278,10 +407,12 @@ public sealed class Markenerkennung(
|
||||
}
|
||||
|
||||
private async Task<bool> SchreibenAsync(
|
||||
FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct)
|
||||
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
|
||||
double von, double bis, CancellationToken ct)
|
||||
{
|
||||
var alt = await db.Marken
|
||||
.FirstOrDefaultAsync(m => m.EpisodeId == folgenId && m.Art == art, ct);
|
||||
var alt = istFilm
|
||||
? await db.Marken.FirstOrDefaultAsync(m => m.MovieId == titelId && m.Art == art, ct)
|
||||
: await db.Marken.FirstOrDefaultAsync(m => m.EpisodeId == titelId && m.Art == art, ct);
|
||||
|
||||
if (alt is not null)
|
||||
{
|
||||
@@ -293,7 +424,8 @@ public sealed class Markenerkennung(
|
||||
|
||||
db.Marken.Add(new Marke
|
||||
{
|
||||
EpisodeId = folgenId,
|
||||
EpisodeId = istFilm ? null : titelId,
|
||||
MovieId = istFilm ? titelId : null,
|
||||
Art = art,
|
||||
VonSekunden = von,
|
||||
BisSekunden = bis,
|
||||
|
||||
@@ -173,6 +173,8 @@ builder.Services.AddSingleton<ScanCoordinator>();
|
||||
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
|
||||
// Vermerk, welche Serie gerade abgetastet wird.
|
||||
builder.Services.AddSingleton<Tonabdruckleser>();
|
||||
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
|
||||
builder.Services.AddSingleton<Bildabtaster>();
|
||||
builder.Services.AddSingleton<Markenerkennung>();
|
||||
builder.Services.AddSingleton<PackageCoordinator>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user