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>
This commit is contained in:
Scarriffle
2026-10-16 16:26:57 +02:00
co-authored by Claude Opus 5
parent 32f592b83e
commit ed13d5fa43
73 changed files with 2137 additions and 694 deletions
@@ -13,7 +13,7 @@ namespace Flaro.Api.Auth;
/// <remarks>
/// <para>
/// <c>Authorization: Bearer &lt;token&gt;</c>. Der Server hasht das Vorgezeigte
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
/// und sucht den Fingerfingerprint — das Token selbst steht nirgends in der
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
/// </para>
/// <para>
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
var fingerabdruck = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
var fingerfingerprint = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
var jetzt = clock.GetUtcNow();
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
@@ -128,8 +128,8 @@ public static class AuthEndpoints
string handle, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
var fingerabdruck = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
var fingerfingerprint = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
return Results.Ok(new CodeState("expired", null));
+5 -5
View File
@@ -26,7 +26,7 @@ public sealed record SystemInfo(
/// lägen die Dateien woanders als die Datenbank sie vermutet.
/// </param>
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
/// <summary>Wie viel auf dem Kratzplatz liegen bleiben darf.</summary>
/// <summary>Wie viel auf dem Transcode-Cache liegen bleiben darf.</summary>
///
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
@@ -42,7 +42,7 @@ public sealed record MaintenanceSettingsResponse(
int OptimizeDays, int ImageDays);
/// <param name="Teile">
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
/// Die Unterordner, wo es welche gibt — heute nur beim Transcode-Cache.
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
@@ -323,15 +323,15 @@ public sealed record ScanStatus(
int? Missing = null,
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
string? Step = null,
/// <summary>Woran die Markensuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
/// <summary>Woran die Segmentsuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
///
/// <remarks>
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Markensuche
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Segmentsuche
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
/// </remarks>
string? Marken = null);
string? Segmente = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
@@ -43,7 +43,7 @@ public static class EinladungEndpoints
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
// wird nur sein Fingerfingerprint; aus der Datenbank lässt sich kein
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht.
var token = Secrets.Create();
@@ -121,7 +121,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
var marken = await MarkenZu(db, [nachbar.Id]);
var segmente = await SegmenteZu(db, [nachbar.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
@@ -138,24 +138,24 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(nachbar.Id),
nachbar.PublishAt,
SeriesId: diese.SeriesId,
Markers: marken[nachbar.Id].ToList()));
Markers: segmente[nachbar.Id].ToList()));
}
/// <summary>Die Marken zu einer Reihe von Folgen.</summary>
/// <summary>Die Segmente zu einer Reihe von Folgen.</summary>
///
/// <remarks>
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
/// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> MarkenZu(
private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
FlaroContext db, IReadOnlyCollection<Guid> folgen)
{
if (folgen.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Marken.AsNoTracking()
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
// In Zeitfolge: ein geteilter Abspann kommt als zwei Marken, und
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
// die Spieler nehmen die erste passende.
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
@@ -164,24 +164,24 @@ public static class LibraryContentEndpoints
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
m.Art == Segmentart.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
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment 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(
private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
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()
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
@@ -190,7 +190,7 @@ public static class LibraryContentEndpoints
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
@@ -424,20 +424,20 @@ public static class LibraryContentEndpoints
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan",
(Guid id, ScanCoordinator koordinator, Markenerkennung erkennung) =>
(Guid id, ScanCoordinator koordinator, Segmenterkennung erkennung) =>
{
// **Auch ohne Scan kann etwas laufen.** Die Markensuche lässt
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
// sich von Hand anstossen; ohne diese Zeile stünde die
// Verwaltung dann auf „idle", während der Server stundenlang
// arbeitet.
var marken = erkennung.Stand(id)?.Text();
var segmente = erkennung.Stand(id)?.Text();
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus(
marken is null ? "idle" : "marken",
segmente is null ? "idle" : "marken",
null, null, null, null, null, null, null, null, null, null, null,
Marken: marken));
Segmente: segmente));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
@@ -452,7 +452,7 @@ public static class LibraryContentEndpoints
metadaten.Skipped, metadaten.Error),
bericht?.Missing,
zustand.Schritt?.Text(),
marken));
segmente));
}).WithName("Scanstand").RequireAuthorization();
}
@@ -592,7 +592,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 segmente = await FilmsegmenteZu(db, ids);
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
@@ -606,7 +606,7 @@ public static class LibraryContentEndpoints
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
Markers: marken[m.Id].ToList()))
Markers: segmente[m.Id].ToList()))
.ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
@@ -802,7 +802,7 @@ public static class LibraryContentEndpoints
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
LibraryId: film.LibraryId,
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
Markers: (await FilmmarkenZu(db, [id]))[id].ToList()));
Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -870,7 +870,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [id]);
var marken = await MarkenZu(db, [id]);
var segmente = await SegmenteZu(db, [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
@@ -883,7 +883,7 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(id),
folge.PublishAt,
SeriesId: folge.SeriesId,
Markers: marken[id].ToList()));
Markers: segmente[id].ToList()));
}).RequireAuthorization();
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
@@ -900,8 +900,8 @@ public static class LibraryContentEndpoints
// 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,
Markenumfang umfang = Markenumfang.Alles) =>
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
@@ -917,7 +917,7 @@ public static class LibraryContentEndpoints
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
// bleiben.
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
Guid id, FlaroContext db, Bildabtaster abtaster, CancellationToken ct) =>
Guid id, FlaroContext db, Frameabtaster abtaster, CancellationToken ct) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
@@ -928,7 +928,7 @@ public static class LibraryContentEndpoints
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
var ab = Math.Max(0, laufzeit.TotalSeconds - Markenerkennung.Suchbereich);
var ab = Math.Max(0, laufzeit.TotalSeconds - Segmenterkennung.Suchbereich);
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
@@ -945,16 +945,16 @@ public static class LibraryContentEndpoints
"text/csv", $"markenkurve-{name}.csv");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine Marke wieder wegnehmen.**
// **Ein Segment 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();
var weg = await db.Segmente.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.
// legte der nächste Scan dieselbe falsches Segment wieder an.
return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
@@ -963,14 +963,14 @@ public static class LibraryContentEndpoints
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
// die Antwort zu verweigern, die er haben will.
app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung,
Markenumfang umfang = Markenumfang.Alles) =>
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
return erkennung.Starten(id, umfang)
? Results.Accepted()
: Einwand.Belegt("Für diese Serie werden gerade Marken gesucht.");
: Einwand.Belegt("Für diese Serie werden gerade Segmente gesucht.");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
@@ -979,8 +979,8 @@ public static class LibraryContentEndpoints
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
// Hintergrund weiter; die Antwort kommt sofort.
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung,
Markenumfang umfang = Markenumfang.Neue) =>
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Neue) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound();
@@ -1077,7 +1077,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var marken = await MarkenZu(db, ids);
var segmente = await SegmenteZu(db, ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
@@ -1091,7 +1091,7 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(f.Id),
f.PublishAt,
SeriesId: id,
Markers: marken[f.Id].ToList())).ToList();
Markers: segmente[f.Id].ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
@@ -106,14 +106,14 @@ public static class PlaybackEndpoints
// `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist
// immer die erste — dort ist die Handlung vorbei.
var abspannAb = zustand.EpisodeId is { } folge
? await db.Marken.AsNoTracking()
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann)
? await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId == folge && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: zustand.MovieId is { } film
? await db.Marken.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Markenart.Abspann)
? await db.Segmente.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
@@ -213,10 +213,10 @@ public static class SettingsEndpoints
return Results.Ok(anlaesse);
}).RequireAuthorization(FlaroPolicies.Administrator);
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
/* ---------- Wartung: was auf dem Transcode-Cache liegen bleiben darf ---------- */
app.MapGet("/api/v1/settings/maintenance", async (
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
Transcodeeinstellungen kratz, Wartungsplaneinstellungen plan) =>
{
var regeln = await kratz.RegelnAsync();
var w = await plan.RegelnAsync();
@@ -230,23 +230,23 @@ public static class SettingsEndpoints
app.MapPut("/api/v1/settings/maintenance", async (
MaintenanceSettingsRequest anfrage, FlaroContext db,
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
Transcodeeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
{
var vorher = await einstellungen.RegelnAsync();
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
// kennt, soll die anderen nicht zurücksetzen.
var nachher = Kratzplatzregeln.Geprueft(
var nachher = Transcoderegeln.Geprueft(
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
var zahl = System.Globalization.CultureInfo.InvariantCulture;
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
await Setzen(db, Transcodeeinstellungen.ObergrenzeSchluessel,
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
await Setzen(db, Transcodeeinstellungen.UnbenutztSchluessel,
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
await Setzen(db, Transcodeeinstellungen.SchonfristSchluessel,
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
var wVorher = await plan.RegelnAsync();
var wNachher = Wartungsregeln.Geprueft(
@@ -93,7 +93,7 @@ public static class StreamEndpoints
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
//
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
// Prozess und ein Kratzplatzordner für nichts — und der
// Prozess und ein Transcodeordner für nichts — und der
// Browser verlöre dabei das, was er von sich aus am besten
// kann: springen über Bereichsanfragen, ohne dass irgendwo
// etwas neu gerechnet wird.
@@ -368,7 +368,7 @@ public static class StreamEndpoints
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
// ganzen Film auf den Kratzplatz.
// ganzen Film auf den Transcode-Cache.
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
@@ -251,12 +251,12 @@ public static class SystemEndpoints
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
// nachsieht — nicht der letzte.
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Kratzplatzpflege pflege) =>
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Transcodepflege pflege) =>
{
Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt
// **Beim Transcode-Cache aufgeschlüsselt.** „39 GB belegt" sagt
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
// sind — und davon hängt ab, ob es ein Problem ist.
name == "Umpacken" ? pflege.Belegung() : null);
@@ -269,7 +269,7 @@ public static class SystemEndpoints
});
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Kratzplatz aufräumen.**
// **Den Transcode-Cache aufräumen.**
//
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
@@ -301,8 +301,8 @@ public static class SystemEndpoints
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
Kratzplatzpflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
Transcodepflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Transcodepflege.Anlass.VonHand, ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.**
+3 -3
View File
@@ -177,8 +177,8 @@ public sealed class PackageCoordinator(
// Umpacken ist der eine, in dem der Platz wirklich gebraucht
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
await bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken);
await bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken);
PlatzPruefen(datei.SizeBytes);
@@ -237,7 +237,7 @@ public sealed class PackageCoordinator(
if (platte.AvailableFreeSpace >= gebraucht) return;
throw new InvalidOperationException(
$"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " +
$"Auf dem Transcode-Cache ({pfade.Transcode}) sind noch " +
$"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " +
$"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein.");
}
+9 -9
View File
@@ -75,7 +75,7 @@ builder.Services.AddRateLimiter(o =>
// und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<Schluesselbilder>();
builder.Services.AddSingleton<Keyframes>();
builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
@@ -172,11 +172,11 @@ builder.Services.AddScoped<MatchApplier>();
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>();
builder.Services.AddSingleton<Fingerprintleser>();
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
builder.Services.AddSingleton<Bildabtaster>();
builder.Services.AddSingleton<Frameabtaster>();
builder.Services.AddSingleton<Kapitelleser>();
builder.Services.AddSingleton<Markenerkennung>();
builder.Services.AddSingleton<Segmenterkennung>();
builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
@@ -184,9 +184,9 @@ builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>();
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Kratzplatzeinstellungen>();
builder.Services.AddSingleton<Kratzplatzpflege>();
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Transcodeeinstellungen>();
builder.Services.AddSingleton<Transcodepflege>();
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
@@ -449,8 +449,8 @@ if (Directory.Exists(oberfläche))
// Stromordner noch da und jedes Teilstück eines abgebrochenen
// Umpackens — und beim Start ist sicher, dass niemand davon noch
// gebraucht wird.
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Sehstandpflege>()
.NachUpdateAsync(leben.ApplicationStopping);
+5 -5
View File
@@ -172,15 +172,15 @@ public sealed class ScanCoordinator(
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
// wandern, und die dazugehörigen Pakete und Untertitel gehören
// zu einem Stand, den es nicht mehr gibt.
_ = bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan);
_ = bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.NachDemScan);
// **Und zuletzt die Marken.** Ein Scan ist der Augenblick, in
// **Und zuletzt die Segmente.** Ein Scan ist der Augenblick, in
// dem neue Folgen dazukommen — und eine Folge ohne Vorspann-
// Marke bekommt nie eine, wenn niemand danach fragt. Der Lauf
// Segment bekommt nie eine, wenn niemand danach fragt. Der Lauf
// hängt sich hinten an und nimmt sich Serie für Serie vor;
// gerechnet wird nur, wo noch nichts steht.
_ = bereich.ServiceProvider.GetRequiredService<Markenerkennung>()
_ = bereich.ServiceProvider.GetRequiredService<Segmenterkennung>()
.NachScanAsync(libraryId);
_zustand[libraryId] = _zustand[libraryId] with
@@ -7,14 +7,14 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Wie weit ein Markenlauf greift.</summary>
/// <summary>Wie weit ein Segmentlauf greift.</summary>
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
/// <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
/// keine Marke: der nächste Lauf übersprang ihn, obwohl er der
/// kein Segment: der nächste Lauf übersprang ihn, obwohl er der
/// interessanteste Fall ist.
/// </para>
/// <para>
@@ -23,18 +23,18 @@ namespace Flaro.Api;
/// steht.
/// </para>
/// </remarks>
public enum Markenumfang
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
/// eine Marke steht.
/// ein Segment steht.
/// </summary>
Luecken,
/// <summary>Alles, auch was längst eine Marke hat.</summary>
/// <summary>Alles, auch was längst ein Segment hat.</summary>
Alles,
}
@@ -43,7 +43,7 @@ public enum Markenumfang
/// <remarks>
/// <para>
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b>
/// <see cref="Tonabgleich"/> sucht das längste Stück, das zwei Tonabdrücke
/// <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
@@ -60,10 +60,10 @@ public enum Markenumfang
/// Zeitlimit zu laufen und am Ende nichts zu haben.
/// </para>
/// </remarks>
public sealed class Markenerkennung(
public sealed class Segmenterkennung(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<Markenerkennung> log)
ILogger<Segmenterkennung> log)
{
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary>
///
@@ -105,7 +105,7 @@ public sealed class Markenerkennung(
/// gleich wieder weggeworfen — gefunden, verworfen, und am Ende stand
/// kein Knopf da, obwohl die Erkennung gearbeitet hatte.
///
/// Weiter als das Suchfenster kann eine Marke ohnehin nicht reichen;
/// 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;
@@ -121,7 +121,7 @@ public sealed class Markenerkennung(
/// <para>
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
/// Markenerkennung angestossen hat — in dasselbe Feld zu schreiben
/// Segmenterkennung angestossen hat — in dasselbe Feld zu schreiben
/// liesse einen beendeten Scan wieder laufend aussehen.
/// </para>
/// <para>
@@ -136,7 +136,7 @@ public sealed class Markenerkennung(
_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, Markenumfang umfang = Markenumfang.Neue)
public bool Starten(Guid serieId, Segmentumfang umfang = Segmentumfang.Neue)
{
if (!_laufend.TryAdd(serieId, true)) return false;
@@ -148,7 +148,7 @@ public sealed class Markenerkennung(
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Markensuche für {Serie} abgebrochen: {Grund}",
log.LogWarning(fehler, "Segmentsuche für {Serie} abgebrochen: {Grund}",
serieId, Fehlertext.FuerMenschen(fehler));
}
finally
@@ -159,12 +159,12 @@ public sealed class Markenerkennung(
return true;
}
/// <summary>Nach einem Scan: jede Serie der Mediathek, der noch Marken fehlen.</summary>
/// <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
/// keine Marke, wenn niemand danach fragt — und niemand fragt, weil man
/// 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>
@@ -177,12 +177,12 @@ public sealed class Markenerkennung(
/// </para>
/// </remarks>
public async Task NachScanAsync(
Guid mediathek, Markenumfang umfang = Markenumfang.Neue,
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<Tonabdruckleser>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var mediathekname = await db.Libraries.AsNoTracking()
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct)
@@ -228,16 +228,16 @@ public sealed class Markenerkennung(
/// <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, Markenumfang umfang, CancellationToken ct)
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(
"Markensuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
"Segmentsuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
+ "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
var marken = 0;
var segmente = 0;
var ohneFund = 0;
var angefasst = 0;
@@ -253,16 +253,16 @@ public sealed class Markenerkennung(
void Melden(Scanschritt schritt) =>
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count };
Melden(new Scanschritt(Scanphase.Marken, titel));
Melden(new Scanschritt(Scanphase.Segmente, titel));
if (!_laufend.TryAdd(id, true)) continue;
try
{
var stand = await LaufenAsync(id, umfang, ct, Melden);
marken += stand.Marken;
segmente += stand.Segmente;
if (!stand.Angefasst) continue;
angefasst++;
if (stand.Marken == 0) ohneFund++;
if (stand.Segmente == 0) ohneFund++;
}
catch (Exception fehler)
{
@@ -270,16 +270,16 @@ public sealed class Markenerkennung(
// `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, "Markensuche für „{Serie}“ abgebrochen: {Grund}",
log.LogWarning(fehler, "Segmentsuche für „{Serie}“ abgebrochen: {Grund}",
titel, Fehlertext.FuerMenschen(fehler));
}
finally { _laufend.TryRemove(id, out _); }
}
log.LogInformation(
"Markensuche in „{Mediathek}“ durch: {Marken} Marke(n) in {Angefasst} von "
"Segmentsuche in „{Mediathek}“ durch: {Segmente} Segment(n) in {Angefasst} von "
+ "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.",
mediathekname, marken, angefasst, serien.Count, ohneFund);
mediathekname, segmente, angefasst, serien.Count, ohneFund);
}
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
@@ -289,16 +289,16 @@ public sealed class Markenerkennung(
/// 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
/// Marke steht.
/// Segment steht.
/// </remarks>
public void MediathekStarten(Guid mediathek, Markenumfang umfang)
public void MediathekStarten(Guid mediathek, Segmentumfang umfang)
{
_ = Task.Run(async () =>
{
try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
catch (Exception fehler)
{
log.LogWarning(fehler, "Markenlauf für Mediathek {Kennung} abgebrochen: {Grund}",
log.LogWarning(fehler, "Segmentlauf für Mediathek {Kennung} abgebrochen: {Grund}",
mediathek, Fehlertext.FuerMenschen(fehler));
}
});
@@ -312,7 +312,7 @@ public sealed class Markenerkennung(
/// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks>
private async Task FilmeAsync(
FlaroContext db, string mediathekname, Guid mediathek, Markenumfang umfang,
FlaroContext db, string mediathekname, Guid mediathek, Segmentumfang umfang,
CancellationToken ct, Action<Scanschritt> melden)
{
// **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem
@@ -322,12 +322,12 @@ public sealed class Markenerkennung(
var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
abfrage = umfang switch
{
Markenumfang.Alles => abfrage,
Segmentumfang.Alles => abfrage,
// Die Lücke: gesucht wurde vielleicht, gefunden nichts.
Markenumfang.Luecken => abfrage.Where(m =>
!db.Marken.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)),
Segmentumfang.Luecken => abfrage.Where(m =>
!db.Segmente.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
_ => abfrage.Where(m =>
!db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)),
!db.Segmentsuchen.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
};
var offen = await abfrage
@@ -346,7 +346,7 @@ public sealed class Markenerkennung(
var film = offen[i];
var titel = Filmname(film.Title, film.Year);
melden(new Scanschritt(Scanphase.Marken, titel, i + 1, offen.Count));
melden(new Scanschritt(Scanphase.Segmente, titel, i + 1, offen.Count));
try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; }
catch (Exception fehler)
@@ -380,16 +380,16 @@ public sealed class Markenerkennung(
/// <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
/// 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, Markenumfang umfang, CancellationToken ct = default)
Guid filmId, Segmentumfang umfang, CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var film = await db.Movies.AsNoTracking()
@@ -416,21 +416,21 @@ public sealed class Markenerkennung(
return false;
}
if (umfang == Markenumfang.Luecken && await db.Marken.AsNoTracking()
.AnyAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct))
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 == Markenumfang.Neue && await db.Markensuchen.AsNoTracking()
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
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, Markenart.Abspann, ct);
await VermerkenAsync(db, filmId, Segmentart.Abspann, ct);
var gefunden = await BildabspannAsync(
db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
@@ -438,12 +438,12 @@ public sealed class Markenerkennung(
if (!gefunden)
{
// Nichts gefunden ist ein Ergebnis. Eine alte Marke, die jetzt
// 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.Marken
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct);
if (alt is not null) db.Marken.Remove(alt);
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);
@@ -456,12 +456,12 @@ public sealed class Markenerkennung(
/// <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 Schlüsselbildern
/// Ü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 Tonabgleich lebt
/// <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
@@ -474,7 +474,7 @@ public sealed class Markenerkennung(
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param>
private async Task<bool> BildabspannAsync(
FlaroContext db, Bildabtaster abtaster, Kapitelleser kapitelleser,
FlaroContext db, Frameabtaster abtaster, Kapitelleser kapitelleser,
Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
CancellationToken ct)
{
@@ -482,7 +482,7 @@ public sealed class Markenerkennung(
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit
// zwanzig Minuten Schlüsselbilder. Und es findet, woran die
// 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".
@@ -492,7 +492,7 @@ public sealed class Markenerkennung(
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(ausKapitel));
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann,
ausKapitel, laufzeit, ct);
}
@@ -500,7 +500,7 @@ public sealed class Markenerkennung(
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 Schlüsselbilder
// 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);
@@ -520,7 +520,7 @@ public sealed class Markenerkennung(
// **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 Schlüsselbildern und sind damit auf
// 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(
@@ -545,28 +545,28 @@ public sealed class Markenerkennung(
(a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}")));
}
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann, fein, ct);
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann, fein, ct);
}
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
///
/// <param name="Marken">Wie viele Marken geschrieben wurden.</param>
/// <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 Marken, bool Angefasst);
private readonly record struct Serienstand(int Segmente, bool Angefasst);
private async Task<Serienstand> LaufenAsync(
Guid serieId, Markenumfang umfang, CancellationToken ct,
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<Tonabdruckleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var serienname = await db.Series.AsNoTracking()
@@ -595,14 +595,14 @@ public sealed class Markenerkennung(
// **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.Markensuchen.AsNoTracking()
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.Marken.AsNoTracking()
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 })
@@ -613,7 +613,7 @@ public sealed class Markenerkennung(
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
serienname, staffeln.Count, folgen.Count);
// Getrennt gezählt: „14 Marken" beantwortet nicht, ob diese Serie
// 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;
@@ -629,25 +629,25 @@ public sealed class Markenerkennung(
// **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 Abdruck. Das ist nicht nur richtig, sondern
// 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 Tonabgleich ist Sache der ganzen Staffel,
// 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(Markenart art) => geordnet.Exists(
bool Luecke(Segmentart art) => geordnet.Exists(
f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art));
var fehlt = new[] { Markenart.Intro, Markenart.Abspann }
.Where(art => umfang == Markenumfang.Alles
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 == Markenumfang.Luecken && Luecke(art)))
|| (umfang == Segmentumfang.Luecken && Luecke(art)))
.ToList();
if (fehlt.Count == 0) continue;
angefasst = true;
// **Der Tonabgleich braucht etwas zum Vergleichen.** Eine
// **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
@@ -657,15 +657,15 @@ public sealed class Markenerkennung(
{
for (var i = 0; i < geordnet.Count; i++)
{
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
var abdruck = await leser.LesenAsync(geordnet[i].Path, ct);
if (abdruck.Count > 0) ganze[geordnet[i].Id] = abdruck;
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 — Tonabgleich entfällt, "
"„{Serie}“, Staffel {Staffel}: nur eine Folge — Fingerprintabgleich entfällt, "
+ "es bleibt das Bild.", serienname, staffel.Key);
}
@@ -675,7 +675,7 @@ public sealed class Markenerkennung(
foreach (var art in tonarten)
{
var abdruecke = new List<(Guid Schlüssel, IReadOnlyList<uint> Abdruck)>();
var fingerprints = new List<(Guid Schlüssel, IReadOnlyList<uint> Fingerprint)>();
var fensteranfang = new Dictionary<Guid, double>();
foreach (var folge in geordnet)
@@ -685,21 +685,21 @@ public sealed class Markenerkennung(
var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count);
if (anzahl < 2) continue;
abdruecke.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList()));
fensteranfang[folge.Id] = vonIndex * Tonabdruckleser.SekundenJeAbdruck;
fingerprints.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList()));
fensteranfang[folge.Id] = vonIndex * Fingerprintleser.SekundenJeFingerprint;
}
if (abdruecke.Count < 2) continue;
if (fingerprints.Count < 2) continue;
var treffer = Tonabgleich.ReiheAbgleichen(
abdruecke, Tonabdruckleser.SekundenJeAbdruck);
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 == Markenart.Intro ? Hoechstlaenge : Hoechstabspann;
var grenze = art == Segmentart.Intro ? Hoechstlaenge : Hoechstabspann;
foreach (var (folgenId, stelle) in treffer)
{
@@ -713,18 +713,18 @@ public sealed class Markenerkennung(
if (!await SchreibenAsync(db, folgenId, istFilm: false, art,
stelle.Von + versatz, stelle.Bis + versatz, ct)) continue;
if (art == Markenart.Intro) gefundenIntro++; else gefundenAbspann++;
if (art == Segmentart.Intro) gefundenIntro++; else gefundenAbspann++;
}
}
var nummern = geordnet.Select(f => f.Id).ToList();
var ueberBild = new HashSet<Guid>();
if (fehlt.Contains(Markenart.Abspann))
if (fehlt.Contains(Segmentart.Abspann))
{
// Auch wenn der Tonabgleich gar nicht erst lief: gesucht wurde,
// 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, Markenart.Abspann,
await VermerkenAsync(db, serieId, staffel.Key, Segmentart.Abspann,
geordnet.Count, ct);
// **Was der Ton nicht fand, sucht das Bild.**
@@ -737,13 +737,13 @@ public sealed class Markenerkennung(
// andere Folge.
//
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
// 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 Marken nicht.
// sonst sieht die Abfrage die eben geschriebenen Segmente nicht.
await db.SaveChangesAsync(ct);
var mitAbspann = await db.Marken.AsNoTracking()
.Where(m => m.Art == Markenart.Abspann
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);
@@ -754,7 +754,7 @@ public sealed class Markenerkennung(
if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
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))
@@ -805,13 +805,13 @@ public sealed class Markenerkennung(
CancellationToken ct)
{
var nummern = folgen.Select(f => f.Id).ToList();
var marken = await db.Marken.AsNoTracking()
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 = marken.Count(m => m.Art == Markenart.Intro);
var mitAbspann = marken.Count(m => m.Art == Markenart.Abspann);
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
@@ -825,8 +825,8 @@ public sealed class Markenerkennung(
foreach (var (id, name) in folgen)
{
var intro = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Intro);
var abspann = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Abspann);
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,
@@ -863,35 +863,35 @@ public sealed class Markenerkennung(
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
}
/// <summary>Welcher Ausschnitt des Abdrucks abgetastet wird.</summary>
/// <summary>Welcher Ausschnitt des Fingerprints abgetastet wird.</summary>
///
/// <remarks>
/// In Werten, nicht in Sekunden: der Abdruck ist eine Reihe, und
/// 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 Abdrucks ist sie.
/// in der Rechnung — die Länge des Fingerprints ist sie.
/// </remarks>
private static (int Von, int Anzahl) Ausschnitt(Markenart art, int werte)
private static (int Von, int Anzahl) Ausschnitt(Segmentart art, int werte)
{
var fenster = (int)Math.Round(
(art == Markenart.Intro ? Introfenster : Abspannfenster)
/ Tonabdruckleser.SekundenJeAbdruck);
(art == Segmentart.Intro ? Introfenster : Abspannfenster)
/ Fingerprintleser.SekundenJeFingerprint);
if (fenster >= werte) return (0, werte);
return art == Markenart.Intro ? (0, fenster) : (werte - fenster, fenster);
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, Markenart art, CancellationToken ct)
FlaroContext db, Guid filmId, Segmentart art, CancellationToken ct)
{
var alt = db.Markensuchen.Local
var alt = db.Segmentsuchen.Local
.FirstOrDefault(x => x.MovieId == filmId && x.Art == art)
?? await db.Markensuchen
?? await db.Segmentsuchen
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
db.Markensuchen.Add(new Markensuche
db.Segmentsuchen.Add(new Segmentsuche
{
MovieId = filmId,
Art = art,
@@ -902,20 +902,20 @@ public sealed class Markenerkennung(
/// <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)
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
// Tonabgleich, einmal vor dem Bild —, und die zweite Abfrage fand
// 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.Markensuchen.Local
var alt = db.Segmentsuchen.Local
.FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art)
?? await db.Markensuchen
?? await db.Segmentsuchen
.FirstOrDefaultAsync(
x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
@@ -926,7 +926,7 @@ public sealed class Markenerkennung(
return;
}
db.Markensuchen.Add(new Markensuche
db.Segmentsuchen.Add(new Segmentsuche
{
SeriesId = serieId,
Staffel = staffel,
@@ -937,11 +937,11 @@ public sealed class Markenerkennung(
}
private Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
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 Marken einer Art auf genau diese Stücke.</summary>
/// <summary>Setzt die Segmente einer Art auf genau diese Stücke.</summary>
///
/// <remarks>
/// <para>
@@ -955,24 +955,24 @@ public sealed class Markenerkennung(
/// <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 keine Marke hätte.
/// keinen Augenblick, in dem der Titel gar kein Segment hätte.
/// </para>
/// </remarks>
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
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.Marken.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct)
: await db.Marken.Where(m => m.EpisodeId == titelId && m.Art == art).ToListAsync(ct);
? 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.Marken.RemoveRange(alte);
db.Segmente.RemoveRange(alte);
foreach (var stück in stücke)
{
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null,
+11 -11
View File
@@ -32,7 +32,7 @@ public sealed record Stromsitzung(
/// <remarks>
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
/// schriebe den ganzen Film auf den Kratzplatz.
/// schriebe den ganzen Film auf den Transcode-Cache.
/// </remarks>
public DateTimeOffset Gesehen { get; set; } = Begonnen;
@@ -61,7 +61,7 @@ public sealed class Streamkoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
FfmpegOptions ffmpeg,
Schluesselbilder schluesselbilder,
Keyframes schluesselbilder,
TimeProvider clock,
ILogger<Streamkoordinator> log) : IDisposable
{
@@ -140,22 +140,22 @@ public sealed class Streamkoordinator(
var stufe = await bereich.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
// **Beim Kopieren wird auf das Keyframe gerundet.**
//
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
// aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
// aufschneiden; ffmpeg beginnt beim Keyframe davor, während der
// Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
// einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
// einer Quelle mit Keyframesn alle fünf Sekunden, Sprung auf
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
//
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
// ein Stück zurückzuspringen.
// **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.**
// **Gesprungen wird auf den Wunsch, gemeldet wird das Keyframe.**
//
// Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
// landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
// landet damit auf dem Keyframe vor dem Wunsch. Bild und Ton
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
// keiner Rechnung hier.
//
@@ -188,14 +188,14 @@ public sealed class Streamkoordinator(
// **Mit Zeiten, und auch im Tagesprotokoll.**
//
// „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen
// dem Antippen und dem ersten Bild liegen die Schlüsselbildsuche, der
// dem Antippen und dem ersten Bild liegen die Keyframesuche, der
// Start von ffmpeg und das erste Segment, und keine dieser Strecken
// war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm —
// sie ist genau das, wonach man im Protokoll sucht.
var meldung =
$"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s"
+ (plan.Hoehe is { } h ? $", auf {h}p" : "")
+ $" — Schlüsselbildsuche {suche} ms, Vorbereitung {uhr.ElapsedMilliseconds} ms.";
+ $" — Keyframesuche {suche} ms, Vorbereitung {uhr.ElapsedMilliseconds} ms.";
log.LogInformation("{Meldung}", meldung);
@@ -472,7 +472,7 @@ public sealed class Streamkoordinator(
/// Löschen in <c>Abraeumen</c> an einem <c>IOException</c>, ist die
/// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer
/// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein
/// zweiter Prozess denselben Kratzplatz benutzt: ein aktiver Ordner
/// zweiter Prozess denselben Transcode-Cache benutzt: ein aktiver Ordner
/// bekommt alle paar Sekunden ein Segment geschrieben.
/// </remarks>
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
@@ -521,7 +521,7 @@ public sealed class Streamkoordinator(
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
// übersehen hiesse, dass der Kratzplatz nur wächst.
// übersehen hiesse, dass der Transcode-Cache nur wächst.
WurzelLeeren();
}
}
@@ -4,22 +4,22 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Die Regeln fürs Aufräumen des Kratzplatzes, aus den Einstellungen.</summary>
/// <summary>Die Regeln fürs Aufräumen des Transcodes, aus den Einstellungen.</summary>
///
/// <remarks>
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
/// Migration.
/// </remarks>
public sealed class Kratzplatzeinstellungen(FlaroContext db)
public sealed class Transcodeeinstellungen(FlaroContext db)
{
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten";
private Kratzplatzregeln? _gemerkt;
private Transcoderegeln? _gemerkt;
public async Task<Kratzplatzregeln> RegelnAsync(CancellationToken ct = default)
public async Task<Transcoderegeln> RegelnAsync(CancellationToken ct = default)
{
if (_gemerkt is { } fertig) return fertig;
@@ -29,9 +29,9 @@ public sealed class Kratzplatzeinstellungen(FlaroContext db)
|| e.Key == SchonfristSchluessel)
.ToDictionaryAsync(e => e.Key, e => e.Value, ct);
var vorgabe = Kratzplatzregeln.Vorgabe;
var vorgabe = Transcoderegeln.Vorgabe;
_gemerkt = Kratzplatzregeln.Geprueft(
_gemerkt = Transcoderegeln.Geprueft(
Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024),
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
(int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes));
@@ -11,7 +11,7 @@ public sealed record Aufraeumbericht(
/// <param name="Name">Der Unterordner — <c>stroeme</c>, <c>packages</c>, <c>untertitel</c>.</param>
public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// <summary>Räumt den Kratzplatz auf.</summary>
/// <summary>Räumt den Transcode-Cache auf.</summary>
///
/// <remarks>
/// <para>
@@ -31,13 +31,13 @@ public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// nebeneinander löschten einander die Dateien unter den Händen weg.
/// </para>
/// </remarks>
public sealed class Kratzplatzpflege(
public sealed class Transcodepflege(
IServiceScopeFactory scopes,
FlaroPaths pfade,
PackageCoordinator pakete,
Streamkoordinator stroeme,
TimeProvider uhr,
ILogger<Kratzplatzpflege> log)
ILogger<Transcodepflege> log)
{
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
@@ -78,7 +78,7 @@ public sealed class Kratzplatzpflege(
if (bericht.Frei > 0)
{
log.LogInformation(
"Kratzplatz aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
"Transcode-Cache aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
}
@@ -100,7 +100,7 @@ public sealed class Kratzplatzpflege(
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Kratzplatzeinstellungen>().RegelnAsync(ct);
.GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene
@@ -137,7 +137,7 @@ public sealed class Kratzplatzpflege(
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
foreach (var a in Kratzplatzauswahl.Wegzuwerfen(
foreach (var a in Transcodeauswahl.Wegzuwerfen(
akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
{
frei += Loeschen(a.Pfad);
+1 -1
View File
@@ -12,7 +12,7 @@ namespace Flaro.Core.Auth;
/// </para>
/// <para>
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein,
/// den nur das anfragende Gerät kennt und der hier nur als Fingerabdruck
/// den nur das anfragende Gerät kennt und der hier nur als Fingerfingerprint
/// liegt. Ohne ihn könnte jemand sechsstellige Zahlen durchprobieren — eine
/// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
/// Mediathek.
+1 -1
View File
@@ -14,7 +14,7 @@ namespace Flaro.Core.Auth;
/// </para>
/// <para>
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
/// <see cref="DeviceToken"/> liegt hier nur der Fingerabdruck; den Link
/// <see cref="DeviceToken"/> liegt hier nur der Fingerfingerprint; den Link
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
/// Datenbank liest, kann daraus keine Einladung bauen.
/// </para>
+2 -2
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Auth;
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
///
/// <remarks>
/// <b>Abgelegt wird nur der Fingerabdruck, nie das Geheimnis.</b> Wer die
/// <b>Abgelegt wird nur der Fingerfingerprint, nie das Geheimnis.</b> Wer die
/// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
@@ -23,7 +23,7 @@ public static class Secrets
public static string Create() =>
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes));
/// <summary>Der Fingerabdruck, wie er in der Datenbank steht.</summary>
/// <summary>Der Fingerfingerprint, wie er in der Datenbank steht.</summary>
public static string Fingerprint(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Delivery;
///
/// <remarks>
/// <para>
/// <b>Warum der Name alles trägt.</b> Im Kratzplatz liegen Dateien, zu denen
/// <b>Warum der Name alles trägt.</b> Im Transcode-Cache liegen Dateien, zu denen
/// es keine Tabelle gibt. Ein Aufräumer muss allein am Namen erkennen können,
/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
/// alles zu behalten oder alles wegzuwerfen.
@@ -21,7 +21,7 @@ namespace Flaro.Core.Delivery;
/// <param name="ObergrenzeBytes">Wie viel insgesamt liegen bleiben darf; <c>0</c> heisst unbegrenzt.</param>
/// <param name="Unbenutzt">Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; <c>Zero</c> heisst nie.</param>
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
public sealed record Kratzplatzregeln(
public sealed record Transcoderegeln(
long ObergrenzeBytes,
TimeSpan Unbenutzt,
TimeSpan Schonfrist)
@@ -34,7 +34,7 @@ public sealed record Kratzplatzregeln(
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
/// Neustart, der den Benutzungszähler verliert.
/// </remarks>
public static Kratzplatzregeln Vorgabe { get; } =
public static Transcoderegeln Vorgabe { get; } =
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
@@ -45,13 +45,13 @@ public sealed record Kratzplatzregeln(
/// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
/// gelten als „aus".
/// </remarks>
public static Kratzplatzregeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
public static Transcoderegeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440)));
}
/// <summary>Eine Datei im Kratzplatz, so wie sie dort liegt.</summary>
/// <summary>Eine Datei im Transcode-Cache, so wie sie dort liegt.</summary>
///
/// <param name="Benutzt">
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
@@ -66,7 +66,7 @@ public readonly record struct Paketakte(
Paketname.Teile? Name);
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
public static class Kratzplatzauswahl
public static class Transcodeauswahl
{
/// <param name="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
/// <param name="bauart">Wie der Server heute baut.</param>
@@ -75,7 +75,7 @@ public static class Kratzplatzauswahl
IReadOnlyList<Paketakte> akten,
IReadOnlyDictionary<Guid, long> bekannt,
int bauart,
Kratzplatzregeln regeln,
Transcoderegeln regeln,
DateTimeOffset jetzt,
Func<string, bool> inBenutzung)
{
@@ -124,8 +124,8 @@ public sealed class Stichwortlink
public Guid? SeriesId { get; init; }
}
/// <summary>Was für ein Abschnitt eine Marke bezeichnet.</summary>
public enum Markenart
/// <summary>Was für ein Abschnitt ein Segment bezeichnet.</summary>
public enum Segmentart
{
/// <summary>Der Vorspann.</summary>
Intro,
@@ -148,7 +148,7 @@ public enum Markenart
/// im Film ist dieselbe.
/// </para>
/// </remarks>
public sealed class Marke
public sealed class Segment
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary>
@@ -162,7 +162,7 @@ public sealed class Marke
/// </remarks>
public Guid? EpisodeId { get; init; }
public Guid? MovieId { get; init; }
public required Markenart Art { get; init; }
public required Segmentart Art { get; init; }
public required double VonSekunden { get; set; }
public required double BisSekunden { get; set; }
@@ -195,21 +195,21 @@ public sealed class Marke
/// Lauf.
/// </para>
/// </remarks>
public sealed class Markensuche
public sealed class Segmentsuche
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Eine Staffel einer Serie — oder ein Film.</summary>
///
/// <remarks>
/// Genau eines von beiden, wie bei <see cref="Marke"/>. Bei einer
/// Genau eines von beiden, wie bei <see cref="Segment"/>. Bei einer
/// Staffel steht die Nummer daneben; ein Film hat keine.
/// </remarks>
public Guid? SeriesId { get; init; }
public int? Staffel { get; init; }
public Guid? MovieId { get; init; }
public required Markenart Art { get; init; }
public required Segmentart Art { get; init; }
/// <summary>Wie viele Folgen die Staffel hatte, als gesucht wurde.</summary>
///
+2 -2
View File
@@ -28,7 +28,7 @@ public enum Scanphase
/// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
/// Verwaltung minutenlang so aus, als täte sich nichts.
/// </remarks>
Marken,
Segmente,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
///
@@ -79,7 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
Scanphase.Marken => $"Suche Vorspann und Abspann: {Was}{zaehler}",
Scanphase.Segmente => $"Suche Vorspann und Abspann: {Was}{zaehler}",
Scanphase.Vermerk => Was,
_ => Was,
};
@@ -18,7 +18,7 @@ namespace Flaro.Core.Library;
/// <para>
/// <b>Transcode-Zwischenstände gehören ausdrücklich nicht hierher.</b> Die
/// liegen unter <c>FLARO_TRANSCODE</c>, auf einem eigenen Volume. Ein
/// Medienordner ist kein Kratzplatz.
/// Medienordner ist kein Transcode-Cache.
/// </para>
/// </remarks>
public static class SidecarNames
+12 -12
View File
@@ -20,7 +20,7 @@ namespace Flaro.Core.Media;
///
/// <param name="Sekunde">Die Stelle im Film.</param>
/// <param name="Dunkelanteil">Wie viel Prozent des Bildes dunkel sind, 0 bis 100.</param>
public readonly record struct Bildprobe(double Sekunde, double Dunkelanteil);
public readonly record struct Frameprobe(double Sekunde, double Dunkelanteil);
/// <summary>Ein Stück Abspann: von wann bis wann.</summary>
///
@@ -80,10 +80,10 @@ public readonly record struct Abspannregeln
/// <b>In Sekunden, nicht in Proben</b> — dieselbe Lehre wie bei
/// <see cref="Schlusstafel"/>, hier eine Zeile höher übersehen. Als
/// Probenzahl hing die Nachsicht daran, wie dicht die Datei ihre
/// Schlüsselbilder setzt: gemessen an „(T)Raumschiff Surprise" kommen
/// Keyframes setzt: gemessen an „(T)Raumschiff Surprise" kommen
/// 1318 Proben auf zwanzig Minuten, also eine alle 0,9 Sekunden —
/// zwei Proben waren dort 1,8 Sekunden Nachsicht. Bei einer Datei mit
/// einem Schlüsselbild alle zehn Sekunden waren es zwanzig. Dasselbe
/// einem Keyframe alle zehn Sekunden waren es zwanzig. Dasselbe
/// Zugeständnis, zehnfach verschieden.
/// </para>
/// </remarks>
@@ -94,7 +94,7 @@ public readonly record struct Abspannregeln
/// <remarks>
/// <b>In Sekunden, nicht in Proben.</b> Eine Logotafel dauert, was sie
/// dauert; wie viele Messpunkte darauf fallen, hängt allein daran, wie
/// dicht die Datei ihre Schlüsselbilder setzt. In Proben gezählt wäre
/// dicht die Datei ihre Keyframes setzt. In Proben gezählt wäre
/// dieselbe Tafel bei der einen Datei verziehen und bei der nächsten
/// ein Abbruch.
/// </remarks>
@@ -156,7 +156,7 @@ public readonly record struct Abspannregeln
/// <b>Reine Rechnung, ohne Datei.</b> Das Abtasten braucht ffmpeg; das
/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen,
/// ohne einen Film vorzuhalten — dieselbe Naht wie bei
/// <see cref="Tonabgleich"/>.
/// <see cref="Fingerprintabgleich"/>.
/// </para>
/// </remarks>
public static class Abspannkante
@@ -165,10 +165,10 @@ public static class Abspannkante
///
/// <param name="proben">
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen
/// ungleichmässig liegen: Schlüsselbilder tun das.
/// ungleichmässig liegen: Keyframes tun das.
/// </param>
public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) =>
IReadOnlyList<Frameprobe> proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _);
/// <param name="grund">
@@ -178,7 +178,7 @@ public static class Abspannkante
/// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten.
/// </param>
public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln, out string grund)
IReadOnlyList<Frameprobe> proben, Abspannregeln regeln, out string grund)
{
grund = "gefunden";
if (proben.Count < 2)
@@ -244,11 +244,11 @@ public static class Abspannkante
/// <remarks>
/// <b>Die Nachsicht zählt Sekunden, nicht Proben.</b> Eine Schrifttafel
/// blitzt auf, ein Logo steht zwei Sekunden im Bild; wie viele
/// Messpunkte darauf fallen, hängt allein an der Schlüsselbilddichte
/// Messpunkte darauf fallen, hängt allein an der Keyframedichte
/// der Datei und schwankt um eine Zehnerpotenz.
/// </remarks>
private static List<Abspannstück> Läufe(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln)
IReadOnlyList<Frameprobe> proben, Abspannregeln regeln)
{
var läufe = new List<Abspannstück>();
double? von = null;
@@ -281,7 +281,7 @@ public static class Abspannkante
/// <summary>Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.</summary>
public static double? HellDavor(
IReadOnlyList<Bildprobe> proben, double dunkelAb, Abspannregeln regeln)
IReadOnlyList<Frameprobe> proben, double dunkelAb, Abspannregeln regeln)
{
double? letzteHelle = null;
foreach (var p in proben)
@@ -297,7 +297,7 @@ public static class Abspannkante
/// <remarks>
/// <para>
/// <b>Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf.</b> Die
/// grobe Reihe stammt aus Schlüsselbildern, und die liegen je nach
/// grobe Reihe stammt aus Keyframesn, und die liegen je nach
/// Aufnahme fünf bis zehn Sekunden auseinander. Den ganzen Bereich fein
/// abzutasten hiesse, ihn zu dekodieren; zehn Sonden von je einer
/// Sekunde kosten einen Bruchteil davon. Denselben Weg geht Jellyfins
@@ -24,7 +24,7 @@ public sealed record Gemeinsamkeit(double AVon, double ABis, double BVon, double
/// </para>
/// <para>
/// <b>Verglichen werden Tonabdrücke, keine Töne.</b> Was hereinkommt, ist je
/// Folge eine Reihe von 32-Bit-Zahlen, eine je Zeitscheibe — der Abdruck,
/// Folge eine Reihe von 32-Bit-Zahlen, eine je Zeitscheibe — der Fingerprint,
/// den Chromaprint aus dem Ton rechnet. Zwei Aufnahmen desselben Stücks
/// ergeben darin fast dieselben Zahlen, auch bei anderer Lautstärke, anderem
/// Codec und anderer Bitrate. Deshalb wird nicht auf Gleichheit geprüft,
@@ -36,7 +36,7 @@ public sealed record Gemeinsamkeit(double AVon, double ABis, double BVon, double
/// sich das Schwierige prüfen, ohne eine Mediendatei vorzuhalten.
/// </para>
/// </remarks>
public static class Tonabgleich
public static class Fingerprintabgleich
{
/// <summary>Wie viele Bits zweier Abdrücke sich unterscheiden dürfen.</summary>
///
@@ -67,7 +67,7 @@ public static class Tonabgleich
/// <summary>Sucht das längste Stück, das <paramref name="a"/> und <paramref name="b"/> teilen.</summary>
///
/// <param name="sekundenJeAbdruck">
/// <param name="sekundenJeFingerprint">
/// Wie viel Zeit eine Zeitscheibe abdeckt. Chromaprint rechnet mit
/// 11025 Hz und 4096 Abtastwerten bei zwei Dritteln Überlappung — das
/// sind rund 0,1238 Sekunden.
@@ -75,14 +75,14 @@ public static class Tonabgleich
public static Gemeinsamkeit? Finden(
IReadOnlyList<uint> a,
IReadOnlyList<uint> b,
double sekundenJeAbdruck,
double sekundenJeFingerprint,
double mindestlänge = StandardMindestlänge,
int bitabstand = StandardBitabstand,
int lücke = StandardLücke)
{
if (a.Count == 0 || b.Count == 0 || sekundenJeAbdruck <= 0) return null;
if (a.Count == 0 || b.Count == 0 || sekundenJeFingerprint <= 0) return null;
var mindestens = (int)Math.Ceiling(mindestlänge / sekundenJeAbdruck);
var mindestens = (int)Math.Ceiling(mindestlänge / sekundenJeFingerprint);
if (mindestens <= 0) mindestens = 1;
Gemeinsamkeit? beste = null;
@@ -90,7 +90,7 @@ public static class Tonabgleich
// **Jede Verschiebung durchprobieren.** Das Intro steht in beiden
// Folgen an einer anderen Stelle; gesucht ist die Verschiebung, bei
// der die meisten Zeitscheiben aufeinanderpassen. Bei zwei Minuten
// Abdruck sind das ein paar Millionen Vergleiche — ein Wimpernschlag
// Fingerprint sind das ein paar Millionen Vergleiche — ein Wimpernschlag
// gegen das Lesen der Datei, aus der sie stammen.
for (var versatz = -(b.Count - 1); versatz < a.Count; versatz++)
{
@@ -102,13 +102,13 @@ public static class Tonabgleich
var lauf = LängsterLauf(a, b, vonA, vonB, länge, bitabstand, lücke);
if (lauf is not var (start, dauer) || dauer < mindestens) continue;
if (beste is not null && dauer * sekundenJeAbdruck <= beste.Laenge) continue;
if (beste is not null && dauer * sekundenJeFingerprint <= beste.Laenge) continue;
beste = new Gemeinsamkeit(
(vonA + start) * sekundenJeAbdruck,
(vonA + start + dauer) * sekundenJeAbdruck,
(vonB + start) * sekundenJeAbdruck,
(vonB + start + dauer) * sekundenJeAbdruck);
(vonA + start) * sekundenJeFingerprint,
(vonA + start + dauer) * sekundenJeFingerprint,
(vonB + start) * sekundenJeFingerprint,
(vonB + start + dauer) * sekundenJeFingerprint);
}
return beste is not null && beste.Laenge >= mindestlänge ? beste : null;
@@ -176,8 +176,8 @@ public static class Tonabgleich
/// </para>
/// </remarks>
public static IReadOnlyDictionary<TSchlüssel, (double Von, double Bis)> ReiheAbgleichen<TSchlüssel>(
IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList<uint> Abdruck)> folgen,
double sekundenJeAbdruck,
IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList<uint> Fingerprint)> folgen,
double sekundenJeFingerprint,
double mindestlänge = StandardMindestlänge)
where TSchlüssel : notnull
{
@@ -186,10 +186,10 @@ public static class Tonabgleich
for (var i = 0; i + 1 < folgen.Count; i++)
{
var (schlüsselA, abdruckA) = folgen[i];
var (schlüsselB, abdruckB) = folgen[i + 1];
var (schlüsselA, fingerprintA) = folgen[i];
var (schlüsselB, fingerprintB) = folgen[i + 1];
var treffer = Finden(abdruckA, abdruckB, sekundenJeAbdruck, mindestlänge);
var treffer = Finden(fingerprintA, fingerprintB, sekundenJeFingerprint, mindestlänge);
if (treffer is null) continue;
// Der erste Fund je Folge gewinnt: er stammt vom Vergleich mit
@@ -79,8 +79,8 @@ public static class ProgressRule
var schwelle = laenge * regeln.GesehenAnteil;
// Der Abspann zieht die Schwelle nach vorn, nie nach hinten: eine
// falsch erkannte Marke bei Minute vierzig darf nicht dazu führen,
// dass eine Folge erst nach der Marke als gesehen gilt.
// falsch erkannte Segment bei Minute vierzig darf nicht dazu führen,
// dass eine Folge erst nach dem Segment als gesehen gilt.
if (abspannAb is { } ab && ab > TimeSpan.Zero && ab < schwelle) schwelle = ab;
return new Outcome(stelle, Watched: stelle >= schwelle);
+5 -5
View File
@@ -76,8 +76,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Marke> Marken => Set<Marke>();
public DbSet<Markensuche> Markensuchen => Set<Markensuche>();
public DbSet<Segment> Segmente => Set<Segment>();
public DbSet<Segmentsuche> Segmentsuchen => Set<Segmentsuche>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>();
@@ -141,7 +141,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
{
e.HasKey(x => x.Id);
// Gesucht wird nur über den Fingerabdruck — und eindeutig, damit
// Gesucht wird nur über den Fingerfingerprint — und eindeutig, damit
// zwei Links niemals dasselbe Konto füttern können.
e.HasIndex(x => x.TokenFingerprint).IsUnique();
e.Property(x => x.TokenFingerprint).HasMaxLength(64);
@@ -308,7 +308,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Anzeige).HasMaxLength(32);
});
b.Entity<Marke>(e =>
b.Entity<Segment>(e =>
{
e.HasKey(x => x.Id);
// **Mehrere Stücke je Art, und das ist der ganze Zweck.** Hier
@@ -326,7 +326,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Art).HasConversion<int>();
});
b.Entity<Markensuche>(e =>
b.Entity<Segmentsuche>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations;
/// <summary>Aus „Marke" wird „Segment" — nur der Name, nicht der Inhalt.</summary>
///
/// <remarks>
/// <para>
/// <b>Von Hand geschrieben, und zwar mit Absicht.</b> Das Gerüst von
/// <c>dotnet ef</c> hätte beide Tabellen gelöscht und neu angelegt — es
/// sieht eine verschwundene und eine neue Tabelle, nicht eine umbenannte.
/// Damit wären jeder erkannte Vorspann, jeder Abspann und jeder Vermerk
/// „hier wurde schon gesucht" weg gewesen, und ein Bestand von
/// viertausend Folgen müsste stundenlang neu durchhört werden.
/// </para>
/// <para>
/// <c>RenameTable</c> und <c>RenameIndex</c> tun dasselbe, ohne eine Zeile
/// anzufassen.
/// </para>
/// </remarks>
public partial class Segmente : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(name: "Marken", newName: "Segmente");
migrationBuilder.RenameTable(name: "Markensuchen", newName: "Segmentsuchen");
migrationBuilder.RenameIndex(
table: "Segmente", name: "IX_Marken_EpisodeId_Art",
newName: "IX_Segmente_EpisodeId_Art");
migrationBuilder.RenameIndex(
table: "Segmente", name: "IX_Marken_MovieId_Art",
newName: "IX_Segmente_MovieId_Art");
migrationBuilder.RenameIndex(
table: "Segmentsuchen", name: "IX_Markensuchen_MovieId_Art",
newName: "IX_Segmentsuchen_MovieId_Art");
migrationBuilder.RenameIndex(
table: "Segmentsuchen", name: "IX_Markensuchen_SeriesId_Staffel_Art",
newName: "IX_Segmentsuchen_SeriesId_Staffel_Art");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameIndex(
table: "Segmente", name: "IX_Segmente_EpisodeId_Art",
newName: "IX_Marken_EpisodeId_Art");
migrationBuilder.RenameIndex(
table: "Segmente", name: "IX_Segmente_MovieId_Art",
newName: "IX_Marken_MovieId_Art");
migrationBuilder.RenameIndex(
table: "Segmentsuchen", name: "IX_Segmentsuchen_MovieId_Art",
newName: "IX_Markensuchen_MovieId_Art");
migrationBuilder.RenameIndex(
table: "Segmentsuchen", name: "IX_Segmentsuchen_SeriesId_Staffel_Art",
newName: "IX_Markensuchen_SeriesId_Staffel_Art");
migrationBuilder.RenameTable(name: "Segmente", newName: "Marken");
migrationBuilder.RenameTable(name: "Segmentsuchen", newName: "Markensuchen");
}
}
@@ -406,76 +406,6 @@ namespace Flaro.Data.Migrations
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.Marke", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<double>("BisSekunden")
.HasColumnType("REAL");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("ErkanntAm")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<double>("VonSekunden")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Art");
b.HasIndex("MovieId", "Art");
b.ToTable("Marken");
});
modelBuilder.Entity("Flaro.Core.Library.Markensuche", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<int>("Folgen")
.HasColumnType("INTEGER");
b.Property<long>("GelaufenAm")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<int?>("Staffel")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MovieId", "Art")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Staffel", "Art")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Markensuchen");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
@@ -788,6 +718,76 @@ namespace Flaro.Data.Migrations
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Segment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<double>("BisSekunden")
.HasColumnType("REAL");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("ErkanntAm")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<double>("VonSekunden")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Art");
b.HasIndex("MovieId", "Art");
b.ToTable("Segmente");
});
modelBuilder.Entity("Flaro.Core.Library.Segmentsuche", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<int>("Folgen")
.HasColumnType("INTEGER");
b.Property<long>("GelaufenAm")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<int?>("Staffel")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MovieId", "Art")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Staffel", "Art")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Segmentsuchen");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
@@ -1201,32 +1201,6 @@ namespace Flaro.Data.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Marke", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Markensuche", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -1278,6 +1252,32 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Segment", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Segmentsuche", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -4,12 +4,12 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Holt den Tonabdruck eines Dateiabschnitts.</summary>
/// <summary>Holt den Fingerprint eines Dateiabschnitts.</summary>
///
/// <remarks>
/// <para>
/// <b>Mit <c>fpcalc</c> aus Chromaprint, nicht von Hand.</b> Was hier
/// gebraucht wird, ist ein Abdruck, der denselben Ton in zwei Dateien
/// gebraucht wird, ist ein Fingerprint, der denselben Ton in zwei Dateien
/// wiedererkennt, auch bei anderem Codec und anderer Lautstärke. Das ist
/// Signalverarbeitung mit einer Fourier-Transformation darin; sie noch
/// einmal zu schreiben hiesse, einen seit zwanzig Jahren erprobten Algorith-
@@ -17,9 +17,9 @@ namespace Flaro.Media;
/// trifft Jellyfins Intro Skipper, und aus demselben Grund.
/// </para>
/// <para>
/// <b>Fehlt das Programm, gibt es eben keine Marken.</b> Es liegt im Abbild
/// <b>Fehlt das Programm, gibt es eben kein Segmente.</b> Es liegt im Abbild
/// (<c>libchromaprint-tools</c>), aber nicht auf jedem Rechner, auf dem
/// jemand den Server baut. Darüber zu stolpern wäre falsch: Marken sind eine
/// jemand den Server baut. Darüber zu stolpern wäre falsch: Segmente sind eine
/// Zugabe, kein Teil der Wiedergabe.
/// </para>
/// <para>
@@ -29,20 +29,20 @@ namespace Flaro.Media;
/// Gelegenheiten für falsche Funde.
/// </para>
/// </remarks>
public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
public sealed class Fingerprintleser(ILogger<Fingerprintleser> log)
{
/// <summary>Wie viel Zeit eine Zahl des Abdrucks abdeckt.</summary>
/// <summary>Wie viel Zeit eine Zahl des Fingerprints abdeckt.</summary>
///
/// <remarks>
/// Chromaprint rechnet mit 11025 Hz, 4096 Abtastwerten je Scheibe und
/// zwei Dritteln Überlappung: 4096/3 / 11025 Sekunden. Die Zahl steht
/// hier, weil nur mit ihr aus einem Abdruckbereich eine Zeitangabe wird.
/// hier, weil nur mit ihr aus einem Fingerprintbereich eine Zeitangabe wird.
/// </remarks>
/// <remarks>
/// Nachgerechnet an einer Datei von 2700 Sekunden: 21 786 Werte, also
/// 0,12393 Sekunden je Wert. Die Formel trifft es auf vier Stellen.
/// </remarks>
public const double SekundenJeAbdruck = 4096.0 / 3.0 / 11025.0;
public const double SekundenJeFingerprint = 4096.0 / 3.0 / 11025.0;
/// <summary>Wo <c>fpcalc</c> liegt — überschreibbar wie ffmpeg selbst.</summary>
public static string Programm =>
@@ -79,7 +79,7 @@ public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
}
}
/// <summary>Der Abdruck der ganzen Tonspur, oder eine leere Reihe.</summary>
/// <summary>Der Fingerprint der ganzen Tonspur, oder eine leere Reihe.</summary>
///
/// <remarks>
/// <para>
@@ -104,7 +104,7 @@ public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
{
if (!await VorhandenAsync(ct))
{
log.LogDebug("Kein fpcalc vorhanden — keine Marken für {Pfad}.", pfad);
log.LogDebug("Kein fpcalc vorhanden — kein Segmente für {Pfad}.", pfad);
return [];
}
@@ -127,7 +127,7 @@ public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Grosszügig: bei einem Film auf einer schlafenden Platte dauert
// allein das Anlaufen Sekunden. Knapp bemessen hiesse, bei jedem
// langsamen Datenträger die Marken zu verlieren.
// langsamen Datenträger die Segmente zu verlieren.
frist.CancelAfter(TimeSpan.FromMinutes(3));
try
@@ -12,7 +12,7 @@ namespace Flaro.Media;
/// <para>
/// <b>Der einzige Teil der Abspannsuche, der ffmpeg anfasst.</b> Was
/// daraus folgt, rechnet <see cref="Abspannkante"/> ohne Datei — dieselbe
/// Naht wie zwischen <c>Tonabdruckleser</c> und <c>Tonabgleich</c>.
/// Naht wie zwischen <c>Fingerprintleser</c> und <c>Fingerprintabgleich</c>.
/// </para>
/// <para>
/// <b>Zwei Griffe, weil zwei Fragen.</b> „Gibt es am Ende überhaupt eine
@@ -23,7 +23,7 @@ namespace Flaro.Media;
/// heisst zehn Sekunden Ungenauigkeit.
/// </para>
/// </remarks>
public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabtaster> log)
public sealed partial class Frameabtaster(FfmpegOptions optionen, ILogger<Frameabtaster> log)
{
/// <summary>Auf welche Breite verkleinert wird, bevor gerechnet wird.</summary>
///
@@ -53,21 +53,21 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// </remarks>
private const string Dunkelschwelle = "24";
/// <summary>Der Dunkelanteil an den Schlüsselbildern eines Abschnitts.</summary>
/// <summary>Der Dunkelanteil an den Keyframesn eines Abschnitts.</summary>
///
/// <remarks>
/// <b>Nur Schlüsselbilder, und das ist der ganze Trick.</b> Gemessen an
/// <b>Nur Keyframes, und das ist der ganze Trick.</b> Gemessen an
/// 180 Sekunden 1080p: jedes zweite Bild zu dekodieren kostet 2,89
/// Sekunden, nur die Schlüsselbilder 0,24. Dafür liegen die Messpunkte
/// Sekunden, nur die Keyframes 0,24. Dafür liegen die Messpunkte
/// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke
/// zu finden, zu wenig für ihre Kante. Die holt
/// <see cref="DunkelanteilAsync"/> nach.
/// </remarks>
public async Task<IReadOnlyList<Bildprobe>> UeberblickAsync(
public async Task<IReadOnlyList<Frameprobe>> UeberblickAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, ab, dauer, nurSchluesselbilder: true, TimeSpan.FromMinutes(5), ct);
pfad, ab, dauer, nurKeyframes: true, TimeSpan.FromMinutes(5), ct);
return Lesen(ausgabe, ab);
}
@@ -75,8 +75,8 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// <summary>Der mittlere Dunkelanteil einer einzelnen Sekunde.</summary>
///
/// <remarks>
/// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst
/// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Schlüsselbild
/// Alle Bilder, nicht nur Keyframes: eine Sekunde enthält sonst
/// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Keyframe
/// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind
/// das zehn Sekunden Bildmaterial, also Millisekunden.
/// </remarks>
@@ -84,7 +84,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
string pfad, double bei, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, Math.Max(0, bei), dauer: 1, nurSchluesselbilder: false,
pfad, Math.Max(0, bei), dauer: 1, nurKeyframes: false,
TimeSpan.FromSeconds(60), ct);
var proben = Lesen(ausgabe, 0);
@@ -111,15 +111,15 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// Kette schrieben denselben Metadatenschlüssel und überschrieben
/// einander; mit <c>split</c> ginge es, kostete aber drei Ausgabewege
/// und eine Zusammenführung. Für etwas, das jemand einmal je Film von
/// Hand anstösst, sind drei Läufe über Schlüsselbilder das günstigere
/// Hand anstösst, sind drei Läufe über Keyframes das günstigere
/// Geschäft.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<Kurvenpunkt>> KurveAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
async Task<IReadOnlyList<Bildprobe>> Lauf(string? zuschnitt) => Lesen(
await LaufenAsync(pfad, ab, dauer, nurSchluesselbilder: true,
async Task<IReadOnlyList<Frameprobe>> Lauf(string? zuschnitt) => Lesen(
await LaufenAsync(pfad, ab, dauer, nurKeyframes: true,
TimeSpan.FromMinutes(10), ct, zuschnitt),
ab);
@@ -128,8 +128,8 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
var rechts = await Lauf("crop=iw*0.2:ih:iw*0.8:0");
// Nach Zeit zusammenführen: die drei Läufe sehen dieselben
// Schlüsselbilder, aber darauf zu bauen wäre eine Annahme.
double BeiZeit(IReadOnlyList<Bildprobe> reihe, double sekunde) =>
// Keyframes, aber darauf zu bauen wäre eine Annahme.
double BeiZeit(IReadOnlyList<Frameprobe> reihe, double sekunde) =>
reihe.Count == 0 ? double.NaN
: reihe.MinBy(p => Math.Abs(p.Sekunde - sekunde)).Dunkelanteil;
@@ -140,7 +140,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
}
private async Task<string> LaufenAsync(
string pfad, double ab, double dauer, bool nurSchluesselbilder,
string pfad, double ab, double dauer, bool nurKeyframes,
TimeSpan frist, CancellationToken ct, string? zuschnitt = null)
{
var start = new ProcessStartInfo(optionen.Ffmpeg)
@@ -157,7 +157,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
start.ArgumentList.Add("-v");
start.ArgumentList.Add("error");
if (nurSchluesselbilder)
if (nurKeyframes)
{
start.ArgumentList.Add("-skip_frame");
start.ArgumentList.Add("nokey");
@@ -231,9 +231,9 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem
/// Dateianfang — deshalb der Aufschlag.
/// </remarks>
public static IReadOnlyList<Bildprobe> Lesen(string ausgabe, double versatz)
public static IReadOnlyList<Frameprobe> Lesen(string ausgabe, double versatz)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
double? zeit = null;
foreach (var zeile in ausgabe.Split('\n'))
@@ -251,7 +251,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
&& double.TryParse(w.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var wert))
{
proben.Add(new Bildprobe(versatz + sekunde, wert));
proben.Add(new Frameprobe(versatz + sekunde, wert));
zeit = null;
}
}
+8 -8
View File
@@ -70,10 +70,10 @@ public static class HlsArgumente
/// </remarks>
public const int SegmentSekunden = 6;
/// <summary>Wie viele Bilder zwischen zwei Schlüsselbildern liegen.</summary>
/// <summary>Wie viele Bilder zwischen zwei Keyframesn liegen.</summary>
///
/// <remarks>
/// <b>Ein Segment muss mit einem Schlüsselbild anfangen.</b> Ohne feste
/// <b>Ein Segment muss mit einem Keyframe anfangen.</b> Ohne feste
/// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die
/// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht.
/// </remarks>
@@ -109,17 +109,17 @@ public static class HlsArgumente
// **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.**
//
// Genau gesprungen schneidet ffmpeg den Ton auf die Sekunde, das
// kopierte Bild aber nicht — es kann erst beim Schlüsselbild davor
// kopierte Bild aber nicht — es kann erst beim Keyframe davor
// anfangen. Dazwischen liegt Bild ohne Ton, und jeder Spieler darf
// diese Lücke deuten, wie er will; hls.js schiebt den Ton nach
// vorn. Gemessen an einer Datei mit Schlüsselbildern alle fünf
// vorn. Gemessen an einer Datei mit Keyframesn alle fünf
// Sekunden: bei einem Sprung auf 17 begann der Ton erst bei 1,96,
// bei 15,014 sogar erst bei 4,98.
//
// `-noaccurate_seek` lässt auch den Ton beim Schlüsselbild
// `-noaccurate_seek` lässt auch den Ton beim Keyframe
// anfangen. Dieselben Sprünge, gemessen: 0,004 und 0,015 — und
// zwar bei *jedem* Wert, nicht nur bei gut getroffenen. Das ist
// der Unterschied zum Runden auf das Schlüsselbild: das muss auf
// der Unterschied zum Runden auf das Keyframe: das muss auf
// die Millisekunde stimmen, sonst wird es schlimmer als vorher.
//
// Beim Umkodieren nicht: dort wird das Bild neu gerechnet und
@@ -222,7 +222,7 @@ public static class HlsArgumente
/// <remarks>
/// <b>Sechs Nachkommastellen, nicht drei.</b> Auf drei gerundet kann ein
/// Sprungpunkt um eine Millisekunde zu klein werden, und eine Millisekunde
/// entscheidet, ob ffmpeg das gemeinte Schlüsselbild erwischt oder das
/// entscheidet, ob ffmpeg das gemeinte Keyframe erwischt oder das
/// davor. Gemessen: bei 15,015 begann der Ton nach 0,004 s, bei 15,014
/// nach 4,98 s.
/// </remarks>
@@ -286,7 +286,7 @@ public static class HlsArgumente
"-level", "4.1",
"-pix_fmt", "yuv420p",
// Feste Schlüsselbildabstände, damit jedes Segment gleich lang wird.
// Feste Keyframeabstände, damit jedes Segment gleich lang wird.
"-g", schluesselabstand,
"-keyint_min", schluesselabstand,
"-sc_threshold", "0",
+5 -5
View File
@@ -7,7 +7,7 @@ using Flaro.Core.Media;
namespace Flaro.Media;
/// <summary>Liest die Kapitelmarken einer Datei.</summary>
/// <summary>Liest die Kapitelsegmente einer Datei.</summary>
///
/// <remarks>
/// <para>
@@ -25,7 +25,7 @@ namespace Flaro.Media;
/// </para>
/// <para>
/// <b>Billig.</b> ffprobe liest dafür nur den Kopf der Datei, kein Bild.
/// Millisekunden gegen zwanzig Minuten Schlüsselbilder.
/// Millisekunden gegen zwanzig Minuten Keyframes.
/// </para>
/// </remarks>
public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> log)
@@ -75,7 +75,7 @@ public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> l
///
/// <remarks>
/// Getrennt vom Aufruf, damit es sich ohne ffprobe prüfen lässt — wie
/// bei <c>Bildabtaster.Lesen</c> und <c>Tonabdruckleser.Lesen</c>.
/// bei <c>Frameabtaster.Lesen</c> und <c>Fingerprintleser.Lesen</c>.
/// </remarks>
public static IReadOnlyList<Kapitel> Lesen(string json)
{
@@ -96,8 +96,8 @@ public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> l
if (!Zeit(eintrag, "start_time", out var von)) continue;
if (!Zeit(eintrag, "end_time", out var bis)) bis = von;
var titel = eintrag.TryGetProperty("tags", out var marken)
&& marken.TryGetProperty("title", out var name)
var titel = eintrag.TryGetProperty("tags", out var segmente)
&& segmente.TryGetProperty("title", out var name)
? name.GetString() ?? ""
: "";
@@ -4,18 +4,18 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Findet das Schlüsselbild, bei dem ein Strom wirklich anfängt.</summary>
/// <summary>Findet das Keyframe, bei dem ein Strom wirklich anfängt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum das überhaupt nötig ist.</b> Beim Umpacken wird das Bild Byte für
/// Byte übernommen, und ein kopierter Strom lässt sich nicht mitten in einer
/// Bildgruppe aufschneiden. Wer bei Sekunde 754 einsteigt, bekommt deshalb
/// Bild ab dem Schlüsselbild davor — der Ton dagegen wird neu gerechnet und
/// Bild ab dem Keyframe davor — der Ton dagegen wird neu gerechnet und
/// fängt genau bei 754 an.
/// </para>
/// <para>
/// <b>Gemessen, nicht vermutet.</b> An einer Quelle mit Schlüsselbildern alle
/// <b>Gemessen, nicht vermutet.</b> An einer Quelle mit Keyframesn alle
/// fünf Sekunden, Sprung auf Sekunde 12: das Bild begann bei 0, der Ton erst
/// bei 1,98 — zwei Sekunden stummes Bild am Anfang. Aneinander ausgerichtet
/// waren beide dabei richtig; falsch war, was der Server dem Spieler über den
@@ -24,13 +24,13 @@ namespace Flaro.Media;
/// </para>
/// <para>
/// <b>Also wird gerundet statt geraten.</b> Der Sprung geht auf das
/// Schlüsselbild, und derselbe Wert geht an den Spieler zurück. Dann fangen
/// Keyframe, und derselbe Wert geht an den Spieler zurück. Dann fangen
/// Bild und Ton an derselben Stelle an, es gibt keine Stille davor, und die
/// Uhr stimmt. Dass man ein paar Sekunden früher einsteigt als gewünscht,
/// fällt beim Weiterschauen niemandem auf — ein Tonversatz schon.
/// </para>
/// </remarks>
public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselbilder> log)
public sealed class Keyframes(FfmpegOptions optionen, ILogger<Keyframes> log)
{
/// <summary>Wie weit vor dem Wunsch gesucht wird.</summary>
///
@@ -46,14 +46,14 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
///
/// <remarks>
/// <para>
/// <b>Ohne diesen Nachlauf fehlte das letzte Schlüsselbild.</b> Hier
/// <b>Ohne diesen Nachlauf fehlte das letzte Keyframe.</b> Hier
/// stand eine halbe Sekunde, und ffprobe hörte damit regelmässig eine
/// ganze Bildgruppe zu früh auf: <c>-read_intervals</c> bricht am Ende
/// des Fensters auf Paketebene ab, und das letzte Bild darin kam gar
/// nicht mehr heraus.
/// </para>
/// <para>
/// Nachgemessen an einer Datei mit Schlüsselbildern alle vier Sekunden,
/// Nachgemessen an einer Datei mit Keyframesn alle vier Sekunden,
/// gegen die Stelle, an der ffmpeg tatsächlich anfängt:
/// </para>
/// <code>
@@ -72,7 +72,7 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
/// </remarks>
private const double Nachlauf = 10;
/// <summary>Das Schlüsselbild bei oder vor <paramref name="ab"/>.</summary>
/// <summary>Das Keyframe bei oder vor <paramref name="ab"/>.</summary>
public async Task<double> DavorAsync(string pfad, double ab, CancellationToken ct = default)
{
if (ab <= 0) return 0;
@@ -86,7 +86,7 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
{
"-v", "error",
"-select_streams", "v:0",
// Nur Schlüsselbilder dekodieren — sonst liest ffprobe jedes
// Nur Keyframes dekodieren — sonst liest ffprobe jedes
// einzelne Bild der halben Minute durch.
"-skip_frame", "nokey",
"-show_entries", "frame=pts_time",
@@ -124,12 +124,12 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
{
// Dauert das Suchen länger als das Lesen der Datei erlaubt, ist der
// Wunsch die bessere Antwort als gar keine.
log.LogWarning("Schlüsselbildsuche in {Pfad} dauerte zu lange.", pfad);
log.LogWarning("Keyframesuche in {Pfad} dauerte zu lange.", pfad);
return ab;
}
catch (Exception fehler) when (fehler is IOException or InvalidOperationException)
{
log.LogWarning(fehler, "Schlüsselbildsuche in {Pfad} ging schief.", pfad);
log.LogWarning(fehler, "Keyframesuche in {Pfad} ging schief.", pfad);
return ab;
}
}