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;
}
}
@@ -220,7 +220,7 @@ public class EinladungTests
Assert.That(einladung.Token, Is.Not.Empty);
Assert.That(roh, Does.Not.Contain(einladung.Token),
"Gespeichert ist nur der Fingerabdruck — aus der Liste darf kein Link zu bauen sein.");
"Gespeichert ist nur der Fingerfingerprint — aus der Liste darf kein Link zu bauen sein.");
}
/// <summary>Ein Tippfehler im Namen darf den Link nicht verbrennen.</summary>
+2 -2
View File
@@ -30,8 +30,8 @@ public sealed class FlaroServer : IDisposable
///
/// <remarks>
/// <b>Sparsam benutzen.</b> Ein Test, der am Server vorbei schreibt,
/// prüft nicht mehr, ob der Server es könnte. Für Marken ist es nötig:
/// sie entstehen aus einem Tonabgleich, und der braucht Mediendateien
/// prüft nicht mehr, ob der Server es könnte. Für Segmente ist es nötig:
/// sie entstehen aus einem Fingerprintabgleich, und der braucht Mediendateien
/// mit echtem Ton, die es hier nicht gibt.
/// </remarks>
public async Task InDerDatenbank(Func<FlaroContext, Task> tun)
@@ -9,12 +9,12 @@ namespace Flaro.Api.Tests;
/// <summary>Vorspann und Abspann auf dem Weg zu den Spielern.</summary>
///
/// <remarks>
/// <b>Ohne Tonabgleich.</b> Ob er findet, was er finden soll, steht in
/// <c>TonabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
/// Hier geht es um das Stück danach: dass eine gefundene Marke auch bei dem
/// <b>Ohne Fingerprintabgleich.</b> Ob er findet, was er finden soll, steht in
/// <c>FingerprintabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
/// Hier geht es um das Stück danach: dass eine gefundene Segment auch bei dem
/// ankommt, der den Knopf zeichnen soll.
/// </remarks>
public class MarkenTests
public class SegmentTests
{
private string _bestand = "";
@@ -67,7 +67,7 @@ public class MarkenTests
}
[Test]
public async Task Eine_Marke_kommt_bei_der_Folge_an()
public async Task Eine_Segment_kommt_bei_der_Folge_an()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
@@ -77,14 +77,14 @@ public class MarkenTests
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
EpisodeId = folge, Art = Markenart.Intro,
EpisodeId = folge, Art = Segmentart.Intro,
VonSekunden = 30, BisSekunden = 65, ErkanntAm = DateTimeOffset.UtcNow,
});
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
EpisodeId = folge, Art = Markenart.Abspann,
EpisodeId = folge, Art = Segmentart.Abspann,
VonSekunden = 2500, BisSekunden = 2600, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
@@ -105,7 +105,7 @@ public class MarkenTests
/// <summary>Auch in der Folgenliste — dort holt die App sie beim Blättern.</summary>
[Test]
public async Task Die_Folgenliste_bringt_die_Marken_mit()
public async Task Die_Folgenliste_bringt_die_Segmente_mit()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
@@ -116,9 +116,9 @@ public class MarkenTests
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
EpisodeId = folge, Art = Markenart.Intro,
EpisodeId = folge, Art = Segmentart.Intro,
VonSekunden = 10, BisSekunden = 40, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
@@ -131,11 +131,11 @@ public class MarkenTests
{
Assert.That(liste!.Items.Single(f => f.Id == folge).Markers, Has.Count.EqualTo(1));
Assert.That(liste.Items.Single(f => f.Id != folge).Markers, Is.Empty,
"eine Folge ohne Marke bekommt eine leere Liste, nicht die der anderen");
"eine Folge ohne Segment bekommt eine leere Liste, nicht die der anderen");
});
}
/// <summary>Ohne erkannte Marken bleibt die Liste leer — kein Knopf, kein Fehler.</summary>
/// <summary>Ohne erkannte Segmente bleibt die Liste leer — kein Knopf, kein Fehler.</summary>
[Test]
public async Task Ohne_Erkennung_ist_die_Liste_leer()
{
@@ -196,11 +196,11 @@ public class MarkenTests
(await client.PostAsync($"/api/v1/series/{serie}/marken", null)).EnsureSuccessStatusCode();
// Der Lauf hängt im Hintergrund; ohne fpcalc ist er sofort durch.
List<Markensuche> vermerke = [];
List<Segmentsuche> vermerke = [];
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.AsNoTracking()
vermerke = await db.Segmentsuchen.AsNoTracking()
.Where(x => x.SeriesId == serie).ToListAsync());
if (vermerke.Count > 0) break;
await Task.Delay(50);
@@ -217,9 +217,9 @@ public class MarkenTests
}
}
/// <summary>Ein Film trägt seine Abspannmarke genauso.</summary>
/// <summary>Ein Film trägt seine Abspannsegment genauso.</summary>
[Test]
public async Task Ein_Film_bekommt_seine_Marke()
public async Task Ein_Film_bekommt_seine_Segment()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -230,9 +230,9 @@ public class MarkenTests
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
MovieId = film, Art = Markenart.Abspann,
MovieId = film, Art = Segmentart.Abspann,
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
@@ -249,7 +249,7 @@ public class MarkenTests
});
}
/// <summary>Eine falsche Marke lässt sich wieder wegnehmen.</summary>
/// <summary>Eine falsches Segment lässt sich wieder wegnehmen.</summary>
///
/// <remarks>
/// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal recht
@@ -257,7 +257,7 @@ public class MarkenTests
/// tun ausser die Datenbank anzufassen.
/// </remarks>
[Test]
public async Task Eine_falsche_Filmmarke_laesst_sich_verwerfen()
public async Task Eine_falsche_Filmsegment_laesst_sich_verwerfen()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -268,14 +268,14 @@ public class MarkenTests
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
MovieId = film, Art = Markenart.Abspann,
MovieId = film, Art = Segmentart.Abspann,
VonSekunden = 60, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
});
db.Markensuchen.Add(new Markensuche
db.Segmentsuchen.Add(new Segmentsuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1,
MovieId = film, Art = Segmentart.Abspann, Folgen = 1,
GelaufenAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
@@ -286,14 +286,14 @@ public class MarkenTests
var vermerke = 0;
await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.CountAsync(x => x.MovieId == film));
vermerke = await db.Segmentsuchen.CountAsync(x => x.MovieId == film));
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach!.Markers, Is.Empty);
Assert.That(vermerke, Is.EqualTo(1),
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an");
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Segment wieder an");
});
}
@@ -301,9 +301,9 @@ public class MarkenTests
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
/// <b>„Schon durchsucht" und „hat ein Segment" sind zwei verschiedene
/// Dinge.</b> Ein Film, bei dem die Suche einmal nichts fand, trägt
/// einen Vermerk und keine Marke — und war damit für jeden weiteren
/// einen Vermerk und kein Segment — und war damit für jeden weiteren
/// Lauf erledigt, obwohl er der interessanteste Fall ist.
/// </para>
/// <para>
@@ -313,9 +313,9 @@ public class MarkenTests
/// </para>
/// </remarks>
[TestCase("Neue", false, TestName = "Neue überspringt, was schon durchsucht ist")]
[TestCase("Luecken", true, TestName = "Luecken nimmt sich den Film ohne Marke vor")]
[TestCase("Luecken", true, TestName = "Luecken nimmt sich den Film ohne Segment vor")]
[TestCase("Alles", true, TestName = "Alles nimmt sich alles vor")]
public async Task Der_Umfang_entscheidet_bei_einem_Film_ohne_Marke(
public async Task Der_Umfang_entscheidet_bei_einem_Film_ohne_Segment(
string umfang, bool erwartetAngefasst)
{
Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -325,7 +325,7 @@ public class MarkenTests
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
// Gesucht wurde schon, gefunden nichts: Vermerk ja, Marke nein.
// Gesucht wurde schon, gefunden nichts: Vermerk ja, Segment nein.
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db =>
{
@@ -334,9 +334,9 @@ public class MarkenTests
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Markensuchen.Add(new Markensuche
db.Segmentsuchen.Add(new Segmentsuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
MovieId = film, Art = Segmentart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
@@ -348,7 +348,7 @@ public class MarkenTests
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
gelaufen = await db.Segmentsuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
if (gelaufen != vorher) break;
await Task.Delay(50);
@@ -360,9 +360,9 @@ public class MarkenTests
: "dieser Umfang muss ihn überspringen");
}
/// <summary>Ein Film mit Marke bleibt bei „nur Lücken" unangetastet.</summary>
/// <summary>Ein Film mit Segment bleibt bei „nur Lücken" unangetastet.</summary>
[Test]
public async Task Luecken_laesst_einen_Film_mit_Marke_in_Ruhe()
public async Task Luecken_laesst_einen_Film_mit_Segment_in_Ruhe()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -377,14 +377,14 @@ public class MarkenTests
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Marken.Add(new Marke
db.Segmente.Add(new Segment
{
MovieId = film, Art = Markenart.Abspann,
MovieId = film, Art = Segmentart.Abspann,
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = vorher,
});
db.Markensuchen.Add(new Markensuche
db.Segmentsuchen.Add(new Segmentsuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
MovieId = film, Art = Segmentart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
@@ -395,10 +395,10 @@ public class MarkenTests
var gelaufen = DateTimeOffset.MinValue;
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
gelaufen = await db.Segmentsuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
Assert.That(gelaufen, Is.EqualTo(vorher),
"wo schon eine Marke steht, ist nichts zu füllen");
"wo schon ein Segment steht, ist nichts zu füllen");
}
}
+6 -6
View File
@@ -333,12 +333,12 @@ public class StreamTests
/// Strom fängt bei null an, der Film bei Minute 30.
/// </para>
/// <para>
/// <b>Und „echt" heisst: beim Schlüsselbild.</b> Dieser Test stand vorher
/// <b>Und „echt" heisst: beim Keyframe.</b> Dieser Test stand vorher
/// auf „genau zwei Sekunden" und hielt damit einen Fehler fest. Ein
/// kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
/// aufschneiden: der Strom fängt beim Schlüsselbild davor an, und wer
/// aufschneiden: der Strom fängt beim Keyframe davor an, und wer
/// trotzdem „zwei" zurückmeldet, verschiebt die Uhr des Spielers um den
/// Unterschied. Diese Testdatei hat genau ein Schlüsselbild, bei null —
/// Unterschied. Diese Testdatei hat genau ein Keyframe, bei null —
/// also ist null die richtige Antwort.
/// </para>
/// </remarks>
@@ -353,10 +353,10 @@ public class StreamTests
Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2),
"Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film.");
Assert.That(strom.StartSeconds, Is.EqualTo(0),
"Diese Datei hat nur ein Schlüsselbild, bei null.");
"Diese Datei hat nur ein Keyframe, bei null.");
}
/// <summary>Schliessen räumt auf — sonst füllt sich der Kratzplatz.</summary>
/// <summary>Schliessen räumt auf — sonst füllt sich der Transcode-Cache.</summary>
[Test]
public async Task Schliessen_raeumt_die_Sitzung_weg()
{
@@ -501,7 +501,7 @@ public class StreamTests
/// <para>
/// <b>Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem
/// gewünschten</b> — und das ist keine Bequemlichkeit. Beim Kopieren
/// beginnt ffmpeg am Schlüsselbild vor dem Wunsch; in einem Prüfclip von
/// beginnt ffmpeg am Keyframe vor dem Wunsch; in einem Prüfclip von
/// vier Sekunden ist das die Null. Der Untertitel muss zu dem passen, was
/// wirklich läuft.
/// </para>
@@ -6,7 +6,7 @@ using Flaro.Core.Delivery;
namespace Flaro.Api.Tests;
/// <summary>Was der Server auf dem Kratzplatz wegräumt — und was nicht.</summary>
/// <summary>Was der Server auf dem Transcode-Cache wegräumt — und was nicht.</summary>
///
/// <remarks>
/// <para>
@@ -17,13 +17,13 @@ namespace Flaro.Api.Tests;
/// <para>
/// <b>Hier wird die Verdrahtung geprüft</b>, nicht die Regel — die steht
/// ohne Platte und ohne Server in
/// <c>Flaro.Core.Tests/KratzplatzregelTests</c>. Was hier zählt: findet der
/// <c>Flaro.Core.Tests/TranscoderegelTests</c>. Was hier zählt: findet der
/// Aufräumer die Dateien, erkennt er, was zur Mediathek gehört, und lässt
/// er das Gültige stehen.
/// </para>
/// </remarks>
[TestFixture]
public sealed class KratzplatzTests
public sealed class TranscodeTests
{
private string _bestand = "";
@@ -41,8 +41,8 @@ public sealed class KratzplatzTests
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
}
/// <summary>Ein Server mit einem eingelesenen Film und bekanntem Kratzplatz.</summary>
private async Task<(HttpClient Client, string Kratzplatz, Guid DateiId)> Aufgesetzt(FlaroServer server)
/// <summary>Ein Server mit einem eingelesenen Film und bekanntem Transcode-Cache.</summary>
private async Task<(HttpClient Client, string Ordner, Guid DateiId)> Aufgesetzt(FlaroServer server)
{
var voll = Path.Combine(_bestand, "Probe (2026)", "Probe (2026).mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
@@ -244,18 +244,18 @@ public sealed class KratzplatzTests
Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart), kilobyte: 8);
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
var kratzplatz = orte!.First(o => o.Variable == "FLARO_TRANSCODE");
var transcode = orte!.First(o => o.Variable == "FLARO_TRANSCODE");
Assert.Multiple(() =>
{
Assert.That(kratzplatz.Teile, Is.Not.Null);
Assert.That(kratzplatz.Teile!.Select(t => t.Name),
Assert.That(transcode.Teile, Is.Not.Null);
Assert.That(transcode.Teile!.Select(t => t.Name),
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" }));
var pakete = kratzplatz.Teile!.First(t => t.Name == "packages");
var pakete = transcode.Teile!.First(t => t.Name == "packages");
Assert.That(pakete.Dateien, Is.EqualTo(1));
Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners");
Assert.That(orte!.First(o => o.Variable == "FLARO_DATA").Teile, Is.Null,
"nur der Kratzplatz wird aufgeschlüsselt");
"nur der Transcode-Cache wird aufgeschlüsselt");
});
}
@@ -261,7 +261,7 @@ public sealed class WartungsplanTests
Assert.That(nachher!.BackupDays, Is.EqualTo(1));
Assert.That(nachher.BackupKeep, Is.EqualTo(10));
Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt");
Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Kratzplatz auch");
Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Transcode-Cache auch");
});
}
}
@@ -28,11 +28,11 @@ public class AbspannkanteTests
/// <summary>Eine Reihe von Messpunkten im Abstand von zehn Sekunden.</summary>
///
/// <param name="abspannAb">Ab wann dunkel. <c>null</c> heisst: nie.</param>
private static List<Bildprobe> Reihe(double von, double bis, double? abspannAb)
private static List<Frameprobe> Reihe(double von, double bis, double? abspannAb)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = von; t <= bis; t += 10)
proben.Add(new Bildprobe(t, abspannAb is { } ab && t >= ab ? 85 : 0));
proben.Add(new Frameprobe(t, abspannAb is { } ab && t >= ab ? 85 : 0));
return proben;
}
@@ -71,7 +71,7 @@ public class AbspannkanteTests
/// <summary>Eine Szene nach dem Abspann nimmt den Fund weg.</summary>
///
/// <remarks>
/// Lieber keine Marke als eine, die über die Szene hinwegspringt.
/// Lieber kein Segment als eine, die über die Szene hinwegspringt.
/// </remarks>
[Test]
public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg()
@@ -223,14 +223,14 @@ public class AbspannAnteilTests
///
/// <remarks>
/// Zwanzig Minuten vor dem Ende einer Folge von fünfundvierzig, an den
/// Schlüsselbildern abgetastet — also alle fünf Sekunden eine Probe.
/// Keyframesn abgetastet — also alle fünf Sekunden eine Probe.
/// </remarks>
private static List<Bildprobe> Fenster(
private static List<Frameprobe> Fenster(
double abspannAb, double imAbspann, double imInhalt = 0)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 5)
proben.Add(new Bildprobe(t, t < abspannAb ? imInhalt : imAbspann));
proben.Add(new Frameprobe(t, t < abspannAb ? imInhalt : imAbspann));
return proben;
}
@@ -300,13 +300,13 @@ public class AbspannSchlusstafelTests
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von;
private static List<Bildprobe> Fenster(double abspannAb, double tafelAb = double.MaxValue)
private static List<Frameprobe> Fenster(double abspannAb, double tafelAb = double.MaxValue)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 5)
{
var dunkel = t >= tafelAb ? 0 : t < abspannAb ? 0 : 85;
proben.Add(new Bildprobe(t, dunkel));
proben.Add(new Frameprobe(t, dunkel));
}
return proben;
}
@@ -336,20 +336,20 @@ public class AbspannSchlusstafelTests
}
}
/// <summary>Dieselbe Szene, zweierlei Schlüsselbilddichte.</summary>
/// <summary>Dieselbe Szene, zweierlei Keyframedichte.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Fehler, für den diese Prüfungen stehen.</b> Die Nachsicht
/// gegenüber hellen Stellen mitten im Abspann war in <i>Proben</i>
/// angegeben, nicht in Sekunden. Was das bedeutet, hängt allein daran,
/// wie dicht eine Datei ihre Schlüsselbilder setzt — und das schwankt um
/// wie dicht eine Datei ihre Keyframes setzt — und das schwankt um
/// eine Zehnerpotenz.
/// </para>
/// <para>
/// Gemessen am Bestand: „(T)Raumschiff Surprise" liefert 1318 Proben auf
/// zwanzig Minuten, also eine alle 0,9 Sekunden. Zwei Proben Nachsicht
/// waren dort 1,8 Sekunden; bei einer Datei mit einem Schlüsselbild alle
/// waren dort 1,8 Sekunden; bei einer Datei mit einem Keyframe alle
/// zehn Sekunden waren dieselben zwei Proben zwanzig Sekunden. Ein
/// fünfsekündiges Logo mitten im Abspann zerriss die Strecke deshalb nur
/// bei der dicht abgetasteten Datei — und genau die fiel durch.
@@ -370,13 +370,13 @@ public class AbspannNachsichtTests
stücke.Count == 0 ? null : stücke[0].Von;
/// <summary>Abspann ab 2340 s, darin fünf Sekunden helles Logo.</summary>
private static List<Bildprobe> Fenster(double takt)
private static List<Frameprobe> Fenster(double takt)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += takt)
{
var imLogo = t >= 2500 && t < 2505;
proben.Add(new Bildprobe(t, t < 2340 ? 0 : imLogo ? 5 : 85));
proben.Add(new Frameprobe(t, t < 2340 ? 0 : imLogo ? 5 : 85));
}
return proben;
}
@@ -408,12 +408,12 @@ public class AbspannNachsichtTests
[Test]
public void Eine_Minute_hell_mittendrin_teilt_den_Abspann_in_zwei_Stuecke()
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 1)
{
// Dunkel ab 2340, aber von 2400 bis 2460 eine Minute hell.
var hell = t < 2340 || (t >= 2400 && t < 2460);
proben.Add(new Bildprobe(t, hell ? 0 : 85));
proben.Add(new Frameprobe(t, hell ? 0 : 85));
}
var stücke = Abspannkante.Suchen(proben, Regeln);
@@ -433,15 +433,15 @@ public class AbspannNachsichtTests
[Test]
public void Der_Grund_wird_benannt()
{
var hell = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Bildprobe(t, 0));
var hell = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Frameprobe(t, 0));
Abspannkante.Suchen(hell, Regeln, out var grund);
Assert.That(grund, Does.Contain("keine dunkle Strecke"));
var kurz = new List<Bildprobe>();
var kurz = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 5)
kurz.Add(new Bildprobe(t, t < 2680 ? 0 : 85));
kurz.Add(new Frameprobe(t, t < 2680 ? 0 : 85));
Abspannkante.Suchen(kurz, Regeln, out var zuKurz);
Assert.That(zuKurz, Does.Contain("zusammen nur"));
@@ -469,13 +469,13 @@ public class AbspannstueckeTests
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Eine Reihe im Sekundentakt, dunkel in den angegebenen Spannen.</summary>
private static List<Bildprobe> Reihe(double bis, params (double Von, double Bis)[] dunkel)
private static List<Frameprobe> Reihe(double bis, params (double Von, double Bis)[] dunkel)
{
var proben = new List<Bildprobe>();
var proben = new List<Frameprobe>();
for (var t = 0.0; t <= bis; t += 1)
{
var istDunkel = Array.Exists(dunkel, d => t >= d.Von && t < d.Bis);
proben.Add(new Bildprobe(t, istDunkel ? 85 : 0));
proben.Add(new Frameprobe(t, istDunkel ? 85 : 0));
}
return proben;
}
@@ -22,7 +22,7 @@ public class SecretsTests
}
[Test]
public void Der_Fingerabdruck_ist_gleichbleibend_und_unterscheidet()
public void Der_Fingerfingerprint_ist_gleichbleibend_und_unterscheidet()
{
var a = Secrets.Create();
var b = Secrets.Create();
@@ -35,9 +35,9 @@ public class SecretsTests
Assert.That(ersterLauf, Has.Length.EqualTo(64));
}
/// <summary>Der Fingerabdruck darf das Geheimnis nicht enthalten.</summary>
/// <summary>Der Fingerfingerprint darf das Geheimnis nicht enthalten.</summary>
[Test]
public void Der_Fingerabdruck_verraet_das_Geheimnis_nicht()
public void Der_Fingerfingerprint_verraet_das_Geheimnis_nicht()
{
var s = Secrets.Create();
Assert.That(Secrets.Fingerprint(s), Does.Not.Contain(s));
@@ -16,13 +16,13 @@ namespace Flaro.Core.Tests;
/// verschiedener Bitrate, durch <c>fpcalc</c> gedreht. Zwanzig Kilobyte
/// Text — der Preis dafür, dass diese Zusage nicht bloss behauptet ist.
/// </remarks>
public class EchteAbdrueckeTests
public class EchteFingerprintsTests
{
private const double JeAbdruck = 4096.0 / 3.0 / 11025.0;
private const double JeFingerprint = 4096.0 / 3.0 / 11025.0;
private static IReadOnlyList<uint>? Lesen(string name)
{
var pfad = Path.Combine(TestContext.CurrentContext.TestDirectory, "Abdruecke", name);
var pfad = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fingerprints", name);
if (!File.Exists(pfad)) return null;
return File.ReadAllText(pfad)
@@ -38,7 +38,7 @@ public class EchteAbdrueckeTests
var b = Lesen("folgeB.txt");
if (a is null || b is null) Assert.Ignore("Keine Abdrücke zum Prüfen da.");
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
var treffer = Fingerprintabgleich.Finden(a, b, JeFingerprint);
TestContext.Out.WriteLine($"A {a.Count} Werte, B {b.Count} Werte");
TestContext.Out.WriteLine(treffer is null
@@ -10,10 +10,10 @@ namespace Flaro.Core.Tests;
/// rechnen kann. Eine Prüfung, die eine Serie mit echten Folgen braucht,
/// liefe nirgends ausser auf dem Rechner dessen, der sie geschrieben hat.
/// </remarks>
public class TonabgleichTests
public class FingerprintabgleichTests
{
/// <summary>Chromaprint mit Standardeinstellungen.</summary>
private const double JeAbdruck = 0.1238;
private const double JeFingerprint = 0.1238;
/// <summary>Eine Folge von Abdrücken, die sich nicht wiederholt.</summary>
///
@@ -40,7 +40,7 @@ public class TonabgleichTests
teile.SelectMany(t => t).ToArray();
/// <summary>Wie viele Zeitscheiben so viele Sekunden sind.</summary>
private static int Scheiben(double sekunden) => (int)Math.Round(sekunden / JeAbdruck);
private static int Scheiben(double sekunden) => (int)Math.Round(sekunden / JeFingerprint);
[Test]
public void Ein_gemeinsamer_Vorspann_wird_gefunden()
@@ -52,7 +52,7 @@ public class TonabgleichTests
// Folge 2: der Vorspann liegt viel später.
var b = Aneinander(Rauschen(Scheiben(70), 4), vorspann, Rauschen(Scheiben(20), 5));
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
var treffer = Fingerprintabgleich.Finden(a, b, JeFingerprint);
Assert.That(treffer, Is.Not.Null);
Assert.Multiple(() =>
@@ -77,7 +77,7 @@ public class TonabgleichTests
var a = Rauschen(Scheiben(120), 11);
var b = Rauschen(Scheiben(120), 12);
Assert.That(Tonabgleich.Finden(a, b, JeAbdruck), Is.Null);
Assert.That(Fingerprintabgleich.Finden(a, b, JeFingerprint), Is.Null);
}
/// <summary>Ein zu kurzes gemeinsames Stück zählt nicht.</summary>
@@ -88,7 +88,7 @@ public class TonabgleichTests
var a = Aneinander(Rauschen(Scheiben(30), 22), kurz, Rauschen(Scheiben(30), 23));
var b = Aneinander(Rauschen(Scheiben(50), 24), kurz, Rauschen(Scheiben(10), 25));
Assert.That(Tonabgleich.Finden(a, b, JeAbdruck), Is.Null);
Assert.That(Fingerprintabgleich.Finden(a, b, JeFingerprint), Is.Null);
}
/// <summary>Ein paar abweichende Bits machen den Fund nicht kaputt.</summary>
@@ -107,7 +107,7 @@ public class TonabgleichTests
var a = Aneinander(Rauschen(Scheiben(5), 32), vorspann, Rauschen(Scheiben(40), 33));
var b = Aneinander(Rauschen(Scheiben(20), 34), leichtAnders, Rauschen(Scheiben(40), 35));
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
var treffer = Fingerprintabgleich.Finden(a, b, JeFingerprint);
Assert.That(treffer, Is.Not.Null);
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0));
@@ -126,7 +126,7 @@ public class TonabgleichTests
var a = Aneinander(Rauschen(Scheiben(5), 42), vorspann, Rauschen(Scheiben(30), 43));
var b = Aneinander(Rauschen(Scheiben(15), 44), mitAnsage, Rauschen(Scheiben(30), 45));
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
var treffer = Fingerprintabgleich.Finden(a, b, JeFingerprint);
Assert.That(treffer, Is.Not.Null);
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0),
@@ -145,7 +145,7 @@ public class TonabgleichTests
Rauschen(Scheiben(5 + i * 10), 60 + i), vorspann, Rauschen(Scheiben(30), 70 + i))));
}
var gefunden = Tonabgleich.ReiheAbgleichen(folgen, JeAbdruck);
var gefunden = Fingerprintabgleich.ReiheAbgleichen(folgen, JeFingerprint);
Assert.That(gefunden, Has.Count.EqualTo(5), "auch die letzte Folge");
for (var i = 0; i < 5; i++)
@@ -160,6 +160,6 @@ public class TonabgleichTests
public void Eine_Folge_allein_ergibt_nichts()
{
var eine = new List<(int, IReadOnlyList<uint>)> { (0, Rauschen(Scheiben(60), 81)) };
Assert.That(Tonabgleich.ReiheAbgleichen(eine, JeAbdruck), Is.Empty);
Assert.That(Fingerprintabgleich.ReiheAbgleichen(eine, JeFingerprint), Is.Empty);
}
}
@@ -25,7 +25,7 @@
</ItemGroup>
<ItemGroup>
<None Include="Abdruecke\**" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fingerprints\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -295,15 +295,15 @@ public sealed class ProgressRuleTests
});
}
/// <summary>Eine Marke hinter der Schwelle verschiebt nichts nach hinten.</summary>
/// <summary>Ein Segment hinter der Schwelle verschiebt nichts nach hinten.</summary>
///
/// <remarks>
/// Sonst machte eine falsch erkannte Marke aus einem Titel, der bei
/// Sonst machte eine falsch erkannte Segment aus einem Titel, der bei
/// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig
/// ist — und niemand sähe, warum.
/// </remarks>
[Test]
public void Eine_spaete_Marke_schiebt_nichts_nach_hinten()
public void Eine_spaete_Segment_schiebt_nichts_nach_hinten()
{
var laenge = TimeSpan.FromMinutes(45);
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
@@ -314,7 +314,7 @@ public sealed class ProgressRuleTests
Assert.That(ergebnis.Watched, Is.True);
}
/// <summary>Ohne Marke bleibt alles, wie es war.</summary>
/// <summary>Ohne Segment bleibt alles, wie es war.</summary>
[Test]
public void Ohne_Abspann_gilt_der_Anteil()
{
@@ -5,7 +5,7 @@ namespace Flaro.Core.Tests;
/// <summary>Der Satz, der in der Verwaltung mitläuft.</summary>
///
/// <remarks>
/// <b>Warum das geprüft wird.</b> Die Markensuche läuft über Stunden und
/// <b>Warum das geprüft wird.</b> Die Segmentsuche läuft über Stunden und
/// hat bisher gar nichts gemeldet — in der Verwaltung stand das Ergebnis
/// des längst beendeten Scans, während der Server noch jede Folge
/// durchhörte. Sie benutzt jetzt denselben Weg wie der Scan, und der Satz
@@ -15,10 +15,10 @@ public class ScanschrittTests
{
/// <summary>Mit Zähler, wenn die Gesamtzahl bekannt ist.</summary>
[Test]
public void Die_Markensuche_nennt_Titel_und_Zaehler()
public void Die_Segmentsuche_nennt_Titel_und_Zaehler()
{
var schritt = new Scanschritt(
Scanphase.Marken, "Biohackers S02E01 · Neuanfang", 12, 95);
Scanphase.Segmente, "Biohackers S02E01 · Neuanfang", 12, 95);
Assert.That(schritt.Text(),
Is.EqualTo("Suche Vorspann und Abspann: Biohackers S02E01 · Neuanfang — 12 von 95"));
@@ -28,7 +28,7 @@ public class ScanschrittTests
[Test]
public void Ohne_Gesamtzahl_steht_nur_der_Titel()
{
var schritt = new Scanschritt(Scanphase.Marken, "Arrival (2016)");
var schritt = new Scanschritt(Scanphase.Segmente, "Arrival (2016)");
Assert.That(schritt.Text(), Is.EqualTo("Suche Vorspann und Abspann: Arrival (2016)"));
}
@@ -5,12 +5,12 @@ namespace Flaro.Core.Tests;
/// <summary>Welche umgepackten Dateien weggeräumt werden.</summary>
///
/// <remarks>
/// <b>Der Anlass: 39 Gigabyte Kratzplatz.</b> Für jede Mitnahme baut der
/// <b>Der Anlass: 39 Gigabyte Transcode-Cache.</b> Für jede Mitnahme baut der
/// Server eine MP4 in Quellgrösse und löschte sie nie. Hier steht die Regel,
/// die das beendet — ohne Platte, ohne Server, damit die Entscheidung selbst
/// nachlesbar ist und nicht nur ihr Ergebnis.
/// </remarks>
public class KratzplatzregelTests
public class TranscoderegelTests
{
private static readonly DateTimeOffset Jetzt = new(2026, 10, 8, 12, 0, 0, TimeSpan.Zero);
private const int Bauart = 2;
@@ -20,7 +20,7 @@ public class KratzplatzregelTests
private const long Gigabyte = 1024L * 1024 * 1024;
/// <summary>Eine Datei, wie sie im Kratzplatz läge.</summary>
/// <summary>Eine Datei, wie sie im Transcode-Cache läge.</summary>
private static Paketakte Akte(
Guid id, long gigabyte, TimeSpan vorWieLange, long? geaendertMs = null, int bauart = Bauart)
{
@@ -36,12 +36,12 @@ public class KratzplatzregelTests
private static IReadOnlyList<string> Weg(
IReadOnlyList<Paketakte> akten,
Kratzplatzregeln? regeln = null,
Transcoderegeln? regeln = null,
IReadOnlyDictionary<Guid, long>? bekannt = null,
Func<string, bool>? inBenutzung = null) =>
Kratzplatzauswahl.Wegzuwerfen(
Transcodeauswahl.Wegzuwerfen(
akten, bekannt ?? Bekannt, Bauart,
regeln ?? Kratzplatzregeln.Vorgabe, Jetzt,
regeln ?? Transcoderegeln.Vorgabe, Jetzt,
inBenutzung ?? (_ => false))
.Select(a => a.Pfad).ToList();
@@ -161,7 +161,7 @@ public class KratzplatzregelTests
[Test]
public void Eine_Obergrenze_von_null_heisst_unbegrenzt()
{
var regeln = Kratzplatzregeln.Vorgabe with { ObergrenzeBytes = 0 };
var regeln = Transcoderegeln.Vorgabe with { ObergrenzeBytes = 0 };
var akten = new[]
{
Akte(EinsId, 500, TimeSpan.FromHours(2)),
@@ -174,7 +174,7 @@ public class KratzplatzregelTests
[Test]
public void Ohne_Altersabbau_bleibt_auch_sehr_Altes_liegen()
{
var regeln = Kratzplatzregeln.Vorgabe with { Unbenutzt = TimeSpan.Zero };
var regeln = Transcoderegeln.Vorgabe with { Unbenutzt = TimeSpan.Zero };
Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromDays(400))], regeln), Is.Empty);
}
@@ -187,7 +187,7 @@ public class KratzplatzregelTests
[Test]
public void Verwaistes_geht_auch_bei_abgeschalteten_Grenzen()
{
var regeln = new Kratzplatzregeln(0, TimeSpan.Zero, TimeSpan.FromMinutes(30));
var regeln = new Transcoderegeln(0, TimeSpan.Zero, TimeSpan.FromMinutes(30));
Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1)], regeln), Has.Count.EqualTo(1));
}
@@ -195,7 +195,7 @@ public class KratzplatzregelTests
[Test]
public void Eine_zu_kurze_Schonfrist_wird_hochgesetzt()
{
var regeln = Kratzplatzregeln.Geprueft(20, 7, 1);
var regeln = Transcoderegeln.Geprueft(20, 7, 1);
Assert.That(regeln.Schonfrist, Is.EqualTo(TimeSpan.FromMinutes(5)),
"eine Minute löschte die Datei mitten im Herunterladen weg");
@@ -204,7 +204,7 @@ public class KratzplatzregelTests
[Test]
public void Null_und_negative_Werte_heissen_abgeschaltet()
{
var regeln = Kratzplatzregeln.Geprueft(0, -3, 30);
var regeln = Transcoderegeln.Geprueft(0, -3, 30);
Assert.Multiple(() =>
{
@@ -8,41 +8,41 @@ namespace Flaro.Media.Tests;
/// <b>Ohne das Programm selbst.</b> Ob Chromaprint rechnen kann, ist nicht
/// unsere Frage; ob wir seine Ausgabe richtig verstehen, schon. Die Zahlen
/// kommen mit Vorzeichen, gemeint sind die Bitmuster — wer das verwechselt,
/// bekommt einen Abdruck, der nie zu einem zweiten passt.
/// bekommt einen Fingerprint, der nie zu einem zweiten passt.
/// </remarks>
public class TonabdruckleserTests
public class FingerprintleserTests
{
[Test]
public void Die_Zahlen_werden_als_Bitmuster_gelesen()
{
var abdruck = Tonabdruckleser.Lesen("DURATION=120\nFINGERPRINT=1,-1,2147483647,-2147483648\n");
var fingerprint = Fingerprintleser.Lesen("DURATION=120\nFINGERPRINT=1,-1,2147483647,-2147483648\n");
Assert.That(abdruck, Is.EqualTo(new uint[] { 1u, 0xFFFFFFFFu, 0x7FFFFFFFu, 0x80000000u }));
Assert.That(fingerprint, Is.EqualTo(new uint[] { 1u, 0xFFFFFFFFu, 0x7FFFFFFFu, 0x80000000u }));
}
[Test]
public void Ohne_Abdruckzeile_kommt_nichts()
public void Ohne_Fingerprintzeile_kommt_nichts()
{
Assert.That(Tonabdruckleser.Lesen("DURATION=120\n"), Is.Empty);
Assert.That(Fingerprintleser.Lesen("DURATION=120\n"), Is.Empty);
}
[Test]
public void Eine_leere_Zeile_ergibt_keinen_Abdruck()
public void Eine_leere_Zeile_ergibt_keinen_Fingerprint()
{
Assert.That(Tonabdruckleser.Lesen("FINGERPRINT=\n"), Is.Empty);
Assert.That(Fingerprintleser.Lesen("FINGERPRINT=\n"), Is.Empty);
}
/// <summary>Eine Zeitscheibe ist rund 124 Millisekunden.</summary>
///
/// <remarks>
/// Daran hängt jede Zeitangabe, die aus einem Abdruck entsteht. Stimmt
/// die Zahl nicht, liegt jede Marke um denselben Faktor daneben — und
/// Daran hängt jede Zeitangabe, die aus einem Fingerprint entsteht. Stimmt
/// die Zahl nicht, liegt jedes Segment um denselben Faktor daneben — und
/// das sähe aus wie ein Fehler in der Erkennung.
/// </remarks>
[Test]
public void Eine_Zeitscheibe_dauert_124_Millisekunden()
{
Assert.That(Tonabdruckleser.SekundenJeAbdruck, Is.EqualTo(0.1238).Within(0.0005));
Assert.That(Fingerprintleser.SekundenJeFingerprint, Is.EqualTo(0.1238).Within(0.0005));
}
}
@@ -59,27 +59,27 @@ public class TonabdruckleserTests
/// Sekunden: <c>-length 0</c> ergibt 21 786 Werte, also 0,1239 Sekunden je
/// Wert; <c>-length 120</c> bricht nach 948 Werten ab.
/// </remarks>
public class TonabdruckRateTests
public class FingerprintRateTests
{
[Test]
public void Die_Rate_stimmt_mit_der_Messung_ueberein()
{
// 21 786 Werte für 2700 Sekunden.
var gemessen = 2700.0 / 21786;
Assert.That(Math.Abs(gemessen - Tonabdruckleser.SekundenJeAbdruck), Is.LessThan(0.0005));
Assert.That(Math.Abs(gemessen - Fingerprintleser.SekundenJeFingerprint), Is.LessThan(0.0005));
}
/// <summary>Eine Stunde Abdruck passt in eine überschaubare Reihe.</summary>
/// <summary>Eine Stunde Fingerprint passt in eine überschaubare Reihe.</summary>
///
/// <remarks>
/// Damit niemand auf die Idee kommt, den Abdruck einer ganzen Folge für
/// Damit niemand auf die Idee kommt, den Fingerprint einer ganzen Folge für
/// zu gross zu halten und wieder in Ausschnitten zu lesen: eine Stunde
/// sind knapp 29 000 Werte, also 116 Kilobyte.
/// </remarks>
[Test]
public void Eine_Stunde_sind_rund_29000_Werte()
{
var werte = (int)(3600 / Tonabdruckleser.SekundenJeAbdruck);
var werte = (int)(3600 / Fingerprintleser.SekundenJeFingerprint);
Assert.That(werte, Is.InRange(28500, 29500));
}
}
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Die Ausgabe des Abtasters lesen — ohne ffmpeg.</summary>
public class BildabtasterLesenTests
public class FrameabtasterLesenTests
{
[Test]
public void Zeit_und_Wert_werden_zu_Proben()
@@ -19,12 +19,12 @@ public class BildabtasterLesenTests
""";
var proben = Bildabtaster.Lesen(ausgabe, versatz: 60);
var proben = Frameabtaster.Lesen(ausgabe, versatz: 60);
Assert.That(proben, Is.EqualTo(new[]
{
new Bildprobe(62.5, 3),
new Bildprobe(65, 84),
new Frameprobe(62.5, 3),
new Frameprobe(65, 84),
}));
}
@@ -38,13 +38,13 @@ public class BildabtasterLesenTests
[Test]
public void Ein_Wert_ohne_Zeit_faellt_heraus()
{
Assert.That(Bildabtaster.Lesen("lavfi.blackframe.pblack=84\n", 0), Is.Empty);
Assert.That(Frameabtaster.Lesen("lavfi.blackframe.pblack=84\n", 0), Is.Empty);
}
[Test]
public void Leere_Ausgabe_ergibt_nichts()
{
Assert.That(Bildabtaster.Lesen("", 0), Is.Empty);
Assert.That(Frameabtaster.Lesen("", 0), Is.Empty);
}
}
@@ -63,7 +63,7 @@ public class BildabtasterLesenTests
/// rotgemeldet — ein fehlendes Werkzeug ist kein Fehler im Quelltext.
/// </para>
/// </remarks>
public class BildabtasterLaufTests
public class FrameabtasterLaufTests
{
private string _ordner = "";
private string _datei = "";
@@ -117,8 +117,8 @@ public class BildabtasterLaufTests
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
}
private static Bildabtaster Abtaster() =>
new(FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance);
private static Frameabtaster Abtaster() =>
new(FfmpegOptions.FromEnvironment(), NullLogger<Frameabtaster>.Instance);
/// <summary>Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.</summary>
[Test]
@@ -198,7 +198,7 @@ public class BildabtasterLaufTests
/// 450 gewesen.
/// </para>
/// </remarks>
public class BildabtasterBittiefeTests
public class FrameabtasterBittiefeTests
{
private string _ordner = "";
@@ -246,8 +246,8 @@ public class BildabtasterBittiefeTests
[Test]
public async Task Acht_und_zehn_Bit_ergeben_dieselbe_Zahl()
{
var abtaster = new Bildabtaster(
FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance);
var abtaster = new Frameabtaster(
FfmpegOptions.FromEnvironment(), NullLogger<Frameabtaster>.Instance);
var acht = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p"), bei: 2);
var zehn = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p10le"), bei: 2);
@@ -241,7 +241,7 @@ public class HlsArgumenteTests
/// <summary>Beim Rechnen wird jedes Segment gleich lang.</summary>
[Test]
public void Beim_Rechnen_stehen_die_Schluesselbilder_fest()
public void Beim_Rechnen_stehen_die_Keyframes_fest()
{
var zeile = Zeile([Bild("hevc"), Ton(1)]);
@@ -197,15 +197,15 @@ public class HlsLaufTests
/// <remarks>
/// <para>
/// <b>Das ist der Test, der gefehlt hat.</b> Die Quelle hat genau ein
/// Schlüsselbild, bei null; jeder Sprung landet also mitten in der
/// Keyframe, bei null; jeder Sprung landet also mitten in der
/// Bildgruppe — der schlimmste Fall. Genau gesprungen schnitte ffmpeg den
/// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge
/// Bild ohne Ton.
/// </para>
/// <para>
/// Zuerst hatte ich dafür den Sprung auf das Schlüsselbild gerundet. Das
/// Zuerst hatte ich dafür den Sprung auf das Keyframe gerundet. Das
/// war zu scharf geschliffen: eine Millisekunde zu klein, und ffmpeg nimmt
/// das Schlüsselbild davor — gemessen wurden aus 0,004 s Abstand dann
/// das Keyframe davor — gemessen wurden aus 0,004 s Abstand dann
/// 4,98. `-noaccurate_seek` braucht diese Genauigkeit nicht.
/// </para>
/// </remarks>
@@ -229,15 +229,15 @@ public class HlsLaufTests
///
/// <remarks>
/// <para>
/// <b>Die Quelle hat genau ein Schlüsselbild, bei null.</b> Ein Sprung auf
/// <b>Die Quelle hat genau ein Keyframe, bei null.</b> Ein Sprung auf
/// Sekunde 4 ist damit der schlimmste Fall: das kopierte Bild muss ganz
/// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier
/// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt.
/// </para>
/// <para>
/// Gemessen an einer Quelle mit Schlüsselbildern alle fünf Sekunden und
/// Gemessen an einer Quelle mit Keyframesn alle fünf Sekunden und
/// einem Sprung auf Sekunde 12 war es genau so: Bild ab 0, Ton ab 1,98.
/// Deshalb rundet der Koordinator den Sprung auf das Schlüsselbild —
/// Deshalb rundet der Koordinator den Sprung auf das Keyframe —
/// hier wird geprüft, dass die Zeile das auch verträgt.
/// </para>
/// </remarks>
@@ -247,7 +247,7 @@ public class HlsLaufTests
var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle);
// Auf dem Schlüsselbild, so wie der Koordinator es rundet.
// Auf dem Keyframe, so wie der Koordinator es rundet.
var (bild, ton) = Anfang(Laufen(quelle, ab: 0));
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Sagt die Schlüsselbildsuche dasselbe wie ffmpeg?</summary>
/// <summary>Sagt die Keyframesuche dasselbe wie ffmpeg?</summary>
///
/// <remarks>
/// <para>
@@ -24,7 +24,7 @@ namespace Flaro.Media.Tests;
/// bleibt.
/// </para>
/// </remarks>
public class SchluesselbilderTests
public class KeyframesTests
{
private string _ordner = "";
private string _datei = "";
@@ -83,8 +83,8 @@ public class SchluesselbilderTests
[TestCase(100.2)]
public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch)
{
var suche = new Schluesselbilder(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
var suche = new Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.Instance);
var gemeldet = await suche.DavorAsync(_datei, wunsch);
var echt = EchterAnfang(wunsch);
@@ -102,8 +102,8 @@ public class SchluesselbilderTests
[TestCase(73.3)]
public async Task Nie_hinter_dem_Wunsch(double wunsch)
{
var suche = new Schluesselbilder(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
var suche = new Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.Instance);
Assert.That(await suche.DavorAsync(_datei, wunsch), Is.LessThanOrEqualTo(wunsch));
}