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
Binary file not shown.
@@ -1,2 +1,5 @@
21:35:00 INFO Server gestartet — 1.0.0 · DEV
21:55:29 INFO Server fährt herunter.
22:00:35 INFO Server gestartet — 1.0.0 · DEV
22:11:06 INFO Server fährt herunter.
22:14:53 INFO Server gestartet — 1.0.0 · DEV
@@ -0,0 +1,17 @@
06:38:47 INFO Server fährt herunter.
06:44:02 INFO Server gestartet — 1.0.0 · DEV
06:57:23 INFO Server fährt herunter.
06:59:45 INFO Server gestartet — 1.0.0 · DEV
17:38:34 INFO Server fährt herunter.
17:57:11 INFO Server gestartet — 1.0.0 · DEV
19:05:40 INFO Server fährt herunter.
19:08:22 INFO Server gestartet — 1.0.0 · DEV
21:03:32 INFO Server fährt herunter.
21:08:04 INFO Server gestartet — 1.0.0 · DEV
21:10:04 INFO Wartung — Sicherung: flaro-2026-10-08-1910.db, 0 MB
21:10:04 INFO Wartung — Optimieren: nichts zusammenzupacken
21:10:04 INFO Wartung — Bilder: kein Bildspeicher da
21:45:40 INFO Server fährt herunter.
21:45:44 INFO Server gestartet — 1.0.0 · DEV
22:06:03 INFO Server fährt herunter.
22:12:27 INFO Server gestartet — 1.0.0 · DEV
@@ -0,0 +1 @@
21:17:00 INFO Wartung — Sicherung: flaro-2026-10-11-1917.db, 0 MB
@@ -0,0 +1 @@
21:26:01 INFO Wartung — Sicherung: flaro-2026-10-14-1926.db, 0 MB
@@ -0,0 +1,2 @@
21:10:21 INFO Wartung — Optimieren: nichts zusammenzupacken
21:10:21 INFO Wartung — Bilder: kein Bildspeicher da
@@ -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));
}
+22 -22
View File
@@ -88,7 +88,7 @@ export type Film = {
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
heroImage?: string | null;
/** Der Abspann, sofern er im Bild gefunden wurde. */
markers?: Marke[] | null;
markers?: Segment[] | null;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
@@ -120,7 +120,7 @@ export type Serie = {
heroImage?: string | null;
};
/** Ein überspringbarer Abschnitt einer Folge. */
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
export type Segment = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
export type Folge = {
id: string; season: number; episode: number;
@@ -129,7 +129,7 @@ export type Folge = {
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
seriesId?: string | null;
/** Vorspann und Abspann, sofern der Server sie gefunden hat. */
markers?: Marke[] | null;
markers?: Segment[] | null;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
@@ -196,11 +196,11 @@ export type Scanstand = {
missing: number | null;
/** Woran der Lauf gerade ist, in einem Satz. */
step: string | null;
/** Woran die Markensuche gerade ist — `null`, wenn keine läuft.
/** Woran die Segmentesuche gerade ist — `null`, wenn keine läuft.
*
* **Ein eigenes Feld, nicht `step`.** Sie hängt sich hinter den Scan und
* läuft weiter, wenn der längst „fertig" meldet. */
marken?: string | null;
segmente?: string | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
@@ -653,19 +653,19 @@ export const naechsteFolgen = (take = 20) =>
/** Eine Folge für sich — tolerant, wie die Nachbarn.
*
* Für den Spieler: er bekommt nur eine Kennung und möchte wissen, ob es
* dazu Marken gibt. Eine 404 heisst „kein Folgentitel", und mehr hängt
* dazu Segmente gibt. Eine 404 heisst „kein Folgentitel", und mehr hängt
* nicht daran. */
/** Stösst die Suche nach Vorspann und Abspann einer Serie an.
*
* Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge
* einer Staffel ein Stück Ton —, und darauf wartet niemand. */
/** Wie weit ein Markenlauf greift.
/** Wie weit ein Segmentelauf greift.
*
* **„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
* Dinge.** Ein Titel, bei dem die Suche einmal nichts fand, trägt einen
* Vermerk und keine Marke — und war damit für jeden weiteren Lauf
* erledigt, obwohl er der interessanteste Fall ist. */
export type Markenumfang =
export type Segmentumfang =
/** Nur, wo noch nie gesucht wurde. */
| 'Neue'
/** Auch, wo gesucht und nichts gefunden wurde — nicht, wo schon eine Marke steht. */
@@ -673,21 +673,21 @@ export type Markenumfang =
/** Alles, auch was längst eine Marke hat. */
| 'Alles';
export const markenSuchen = (serieId: string, umfang: Markenumfang = 'Alles') =>
export const segmenteSuchen = (serieId: string, umfang: Segmentumfang = 'Alles') =>
anfrage<void>(
`/series/${serieId}/marken?umfang=${umfang}`, { method: 'POST' });
/** Die Marken eines Titels — Folge oder Film.
/** Die Segmente eines Titels — Folge oder Film.
*
* **Eine Kennung, zwei mögliche Wege.** Der Spieler bekommt nur eine
* Kennung und weiss nicht, was dahintersteckt; erst die Antwort sagt es.
* Eine 404 auf beiden Wegen heisst: keine Marken, und mehr hängt nicht
* Eine 404 auf beiden Wegen heisst: keine Segmente, und mehr hängt nicht
* daran. */
export async function markenHolen(titelId: string): Promise<Marke[]> {
export async function segmenteHolen(titelId: string): Promise<Segment[]> {
const folge = await folgeLesen(titelId);
if (folge) return folge.markers ?? [];
try {
const f = await anfrage<{ markers?: Marke[] | null }>(`/movies/${titelId}`);
const f = await anfrage<{ markers?: Segment[] | null }>(`/movies/${titelId}`);
return f.markers ?? [];
} catch {
return [];
@@ -698,7 +698,7 @@ export async function markenHolen(titelId: string): Promise<Marke[]> {
*
* **Antwortet sofort, arbeitet weiter.** Der Lauf liest je Folge ein Stück
* Ton; über fünfundneunzig Serien sind das Stunden. */
export const mediathekMarken = (id: string, umfang: Markenumfang) =>
export const mediathekSegmente = (id: string, umfang: Segmentumfang) =>
anfrage<void>(`/libraries/${id}/marken?umfang=${umfang}`, { method: 'POST' });
/** Die Adresse der Messkurve eines Films — zum Herunterladen.
@@ -706,15 +706,15 @@ export const mediathekMarken = (id: string, umfang: Markenumfang) =>
* **Keine Erkennung, sondern Zahlen zum Ansehen.** Welche Schwelle bei
* einem Abspann über laufendem Bild trägt, lässt sich nur an der Kurve
* entscheiden, nicht am Ergebnis der Erkennung. */
export const markenkurveAdresse = (filmId: string) =>
export const segmentkurveAdresse = (filmId: string) =>
`/api/v1/movies/${filmId}/markenkurve`;
/** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */
export const filmmarkenVerwerfen = (filmId: string) =>
/** Nimmt die Segmente eines Films wieder weg — wenn die Erkennung danebenlag. */
export const filmsegmenteVerwerfen = (filmId: string) =>
anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' });
/** Stösst die Abspannsuche für einen Film an. */
export const filmmarkenSuchen = (filmId: string, umfang: Markenumfang = 'Alles') =>
export const filmsegmenteSuchen = (filmId: string, umfang: Segmentumfang = 'Alles') =>
anfrage<void>(`/movies/${filmId}/marken?umfang=${umfang}`, { method: 'POST' });
export async function folgeLesen(id: string): Promise<Folge | null> {
@@ -819,7 +819,7 @@ export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
method: 'POST', body: JSON.stringify(wunsch),
});
/** Beendet einen Strom und räumt seinen Kratzplatz weg. */
/** Beendet einen Strom und räumt seinen Transcode-Cache weg. */
export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
@@ -1190,7 +1190,7 @@ export const statistik = (wochen = 12) =>
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
/** Nur beim Kratzplatz: die Unterordner einzeln. */
/** Nur beim Transcode-Cache: die Unterordner einzeln. */
teile: { name: string; frei: number; dateien: number }[] | null;
};
@@ -1202,11 +1202,11 @@ export type Aufraeumbericht = {
teile: { name: string; frei: number; dateien: number }[];
};
/** Räumt den Kratzplatz auf — Verwaistes immer, der Rest nach der Obergrenze.
/** Räumt den Transcode-Cache auf — Verwaistes immer, der Rest nach der Obergrenze.
*
* POST und nicht DELETE wie beim Bildspeicher: dort wird ein Ordner geleert,
* hier eine Regel angewandt. */
export const kratzplatzAufraeumen = () =>
export const transcodeAufraeumen = () =>
anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' });
export type Wartungseinstellung = {
+28 -28
View File
@@ -1,7 +1,7 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke,
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
type Stromauskunft, type Spurauskunft, type Folge, type Segment,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
@@ -386,10 +386,10 @@ export function spieler(anfrage: Spieleranfrage): void {
// **Zwei Knöpfe, nicht einer.** Im Abspann einer Serie gibt es zwei
// vernünftige Antworten — weiterschauen oder den Abspann überspringen —
// und welche gemeint ist, weiss nur der Zuschauer.
const markenreihe = document.createElement('div');
markenreihe.className = 'pl-marken hidden';
const segmentreihe = document.createElement('div');
segmentreihe.className = 'pl-segmente hidden';
schicht.append(video, dreher, mitte, kopf, bedienung, markenreihe, meldungsfeld, kleinleiste);
schicht.append(video, dreher, mitte, kopf, bedienung, segmentreihe, meldungsfeld, kleinleiste);
document.body.appendChild(schicht);
document.body.classList.add('pl-offen');
@@ -626,7 +626,7 @@ export function spieler(anfrage: Spieleranfrage): void {
let vorige: Folge | null = null;
let naechste: Folge | null = null;
/** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */
let marken: Marke[] = [];
let segmente: Segment[] = [];
// **Eine Folge weiter, eine zurück.**
//
@@ -670,11 +670,11 @@ export function spieler(anfrage: Spieleranfrage): void {
const [davor, danach, gefunden] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'),
markenHolen(anfrage.titelId),
segmenteHolen(anfrage.titelId),
]);
vorige = davor;
naechste = danach;
marken = gefunden;
segmente = gefunden;
if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null);
@@ -827,7 +827,7 @@ export function spieler(anfrage: Spieleranfrage): void {
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
markenstand(jetzt);
segmentstand(jetzt);
}
/** Zeigt den Überspringen-Knopf, solange man in einer Marke steht.
@@ -841,8 +841,8 @@ export function spieler(anfrage: Spieleranfrage): void {
* mehrmals je Sekunde; die Knöpfe jedesmal neu zu bauen hiesse, einen
* Klick mitten im Drücken zu verlieren.
*/
let markenstandJetzt = '';
/** Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
let segmentstandJetzt = '';
/** Ob die Segmente-Knöpfe inzwischen an der Bedienung hängen.
*
* **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt ihre
* Knöpfe von selbst — sonst müsste man erst das Bild anfassen, um das
@@ -850,27 +850,27 @@ export function spieler(anfrage: Spieleranfrage): void {
* Sobald man sie aber einmal eingeblendet hat, gehören die Knöpfe dazu
* und gehen mit ihr. Keine Frist, kein Zeitgeber: der Zuschauer sagt
* selbst, wann er genug gesehen hat. */
let markenGebunden = false;
let markeLaeuft = false;
let segmentGebunden = false;
let segmentLaeuft = false;
function markenstand(jetzt: number): void {
const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
function segmentstand(jetzt: number): void {
const drin = segmente.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
const abspann = drin?.kind === 'credits';
const weiter = abspann && naechste?.files[0] != null;
const kennung = `${drin?.startSeconds ?? ''}|${weiter}`;
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
// bis die Bedienung das nächste Mal auftaucht.
if (kennung !== markenstandJetzt) markenGebunden = false;
if (kennung !== segmentstandJetzt) segmentGebunden = false;
markeLaeuft = drin != null;
markenreihe.classList.toggle(
segmentLaeuft = drin != null;
segmentreihe.classList.toggle(
'hidden',
!drin || (markenGebunden && schicht.classList.contains('pl-still')));
!drin || (segmentGebunden && schicht.classList.contains('pl-still')));
if (!drin || kennung === markenstandJetzt) return;
markenstandJetzt = kennung;
markenreihe.replaceChildren();
if (!drin || kennung === segmentstandJetzt) return;
segmentstandJetzt = kennung;
segmentreihe.replaceChildren();
// **Im Abspann einer Serie zuerst die nächste Folge.** Das ist, was man
// dort will; „überspringen" steht daneben für den, der den Abspann
@@ -878,7 +878,7 @@ export function spieler(anfrage: Spieleranfrage): void {
if (weiter) {
const w = knopf('Nächste Folge', 'btn btn-primary');
w.onclick = e => { e.stopPropagation(); folgeWechseln(naechste); };
markenreihe.appendChild(w);
segmentreihe.appendChild(w);
}
const u = knopf(
@@ -894,11 +894,11 @@ export function spieler(anfrage: Spieleranfrage): void {
// heisst überspringen Schluss" — und damit bekam niemand die Szene
// je zu sehen. Geschlossen wird nur, wenn nach dieser Marke nichts
// mehr kommt.
const danach = marken.some(m => m.startSeconds >= drin.endSeconds - 1);
const danach = segmente.some(m => m.startSeconds >= drin.endSeconds - 1);
if (abspann && !weiter && !danach) { void beenden(); return; }
void springen(drin.endSeconds);
};
markenreihe.appendChild(u);
segmentreihe.appendChild(u);
}
/** Ans Ende springen und zumachen — für den Abspann ohne Nachfolger.
@@ -1365,11 +1365,11 @@ export function spieler(anfrage: Spieleranfrage): void {
// Mausbewegung, auch wenn die Bedienung längst da ist. Das jedes Mal
// als „einmal gezeigt" zu werten hiesse, dem Intro seinen Knopf zu
// nehmen, bevor es ihn je hatte.
if (schicht.classList.contains('pl-still')) markenGebunden = true;
if (schicht.classList.contains('pl-still')) segmentGebunden = true;
schicht.classList.remove('pl-still');
// Im Stehen rechnet `markenstand` nicht weiter: wer pausiert im Intro
// Im Stehen rechnet `segmentstand` nicht weiter: wer pausiert im Intro
// die Maus bewegt, bekäme die Bedienung zurück und den Knopf nicht.
if (markeLaeuft) markenreihe.classList.remove('hidden');
if (segmentLaeuft) segmentreihe.classList.remove('hidden');
verbergenPlanen();
}
function verbergenPlanen(): void {
+4 -4
View File
@@ -659,7 +659,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
* wiederholen hiesse, vier gleich aussehende Zahlen nebeneinanderzustellen,
* von denen keine sagt, woher sie kommt. Das Kürzel sagt es in zwei Zeichen.
*
* **Und kein Logo.** Die Marken von IMDb und Rotten Tomatoes mitzuliefern
* **Und kein Logo.** Die Segmente von IMDb und Rotten Tomatoes mitzuliefern
* hiesse, fremde Zeichen auszuliefern — mit allem, was daran hängt. */
.bewertung.fremd { font-weight: 500; }
.bewertungsquelle {
@@ -685,7 +685,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
/* **Der Primärknopf ist hell, nicht farbig.**
*
* Genau das macht Netflix: der Abspielknopf ist weiss mit schwarzer Schrift,
* die Markenfarbe erscheint nur im Schriftzug. Der Akzent sass hier auf neun
* die Segmentefarbe erscheint nur im Schriftzug. Der Akzent sass hier auf neun
* Dingen gleichzeitig — Marke, Navigation, Knopf, Fortschritt, Plakette,
* Stern, Fokus, Filter, Kontokreis. Wenn alles Akzent ist, markiert er
* nichts. */
@@ -928,7 +928,7 @@ textarea.feld {
* Leiste gar nicht. Hier liegt er über dem Bild, rechts über der Stelle,
* an der die Leiste erscheinen würde, und bleibt stehen, solange die Marke
* läuft. */
.pl-marken {
.pl-segmente {
position: absolute;
right: var(--s-section);
bottom: calc(var(--s-section) * 3.5);
@@ -936,7 +936,7 @@ textarea.feld {
display: flex;
gap: var(--s-snug);
}
.pl-marken .btn { box-shadow: 0 2px 12px rgba(0, 0, 0, .6); }
.pl-segmente .btn { box-shadow: 0 2px 12px rgba(0, 0, 0, .6); }
.pl-unten {
display: grid; grid-template-columns: 1fr auto 1fr;
+13 -13
View File
@@ -2,8 +2,8 @@ import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen,
filmmarkenSuchen, filmmarkenVerwerfen, markenkurveAdresse,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, segmenteSuchen,
filmsegmenteSuchen, filmsegmenteVerwerfen, segmentkurveAdresse,
} from '../api';
import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
@@ -33,7 +33,7 @@ type Art = 'filme' | 'serien';
* die Besetzung mit runden Porträts. */
/** Wie die fremden Quellen heissen — kurz an der Kachel, lang im Tooltip.
*
* **Kein Logo.** Die Marken von IMDb und Rotten Tomatoes mitzuliefern
* **Kein Logo.** Die Segmente von IMDb und Rotten Tomatoes mitzuliefern
* hiesse, fremde Zeichen auszuliefern; ein Kürzel sagt dasselbe und gehört
* uns.
*
@@ -513,7 +513,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'filme') {
reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen',
async () => {
await filmmarkenSuchen(eintrag.id, 'Alles');
await filmsegmenteSuchen(eintrag.id, 'Alles');
hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür '
+ 'die letzten zwanzig Minuten an — das dauert einen Augenblick.');
// Kurz warten und nachsehen: anders als bei einer Serie ist das
@@ -526,7 +526,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
// begründen. Der Knopf lädt sie als Tabelle herunter.
reihe.appendChild(rundKnopf('protokoll', false, 'Messwerte des Abspanns holen',
async () => {
window.location.href = markenkurveAdresse(eintrag.id);
window.location.href = segmentkurveAdresse(eintrag.id);
hinweisZeigen('Die Messung läuft — der Server sieht sich dafür die '
+ 'letzten zwanzig Minuten dreimal an.');
}));
@@ -534,9 +534,9 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
// **Nur, wenn es etwas zu verwerfen gibt.** Ein Knopf, der nichts
// wegnehmen kann, ist eine Behauptung über einen Zustand.
if ('markers' in eintrag && (eintrag.markers?.length ?? 0) > 0) {
reihe.appendChild(rundKnopf('schliessen', false, 'Abspannmarke verwerfen',
reihe.appendChild(rundKnopf('schliessen', false, 'Abspannsegment verwerfen',
async () => {
await filmmarkenVerwerfen(eintrag.id);
await filmsegmenteVerwerfen(eintrag.id);
hinweisZeigen('Marke entfernt. Der nächste Scan legt sie nicht wieder an.');
await laden();
}));
@@ -546,7 +546,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'serien') {
reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen',
async () => {
await markenSuchen(eintrag.id, 'Alles');
await segmenteSuchen(eintrag.id, 'Alles');
hinweisZeigen('Vorspann und Abspann werden gesucht. '
+ 'Der Server liest dafür jede Folge einmal durch — das dauert ein paar Minuten.');
}));
@@ -1112,14 +1112,14 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
* gar nicht gefunden wurde oder bloss nicht angezeigt wird, ist von
* aussen nicht zu unterscheiden — und beides hat eine andere Ursache.
* Einmal nachsehen zu können erspart eine Runde Raten. */
function markenwort(marken: Folge['markers']): string {
function segmentwort(segmente: Folge['markers']): string {
if (!istAdmin()) return '';
if (!marken || marken.length === 0) return 'keine Marken';
if (!segmente || segmente.length === 0) return 'keine Segmente';
// **Mit Zeiten, nicht bloss mit Namen.** Dass eine Marke da ist, sagt
// noch nicht, dass sie an der richtigen Stelle steht — und genau das
// ist der Unterschied zwischen „die Erkennung hat nichts gefunden"
// und „der Knopf kommt zur falschen Minute".
return marken
return segmente
.map(m => `${m.kind === 'intro' ? 'Vorspann' : 'Abspann'} `
+ `${dauer(m.startSeconds)}–${dauer(m.endSeconds)}`)
.join(' · ');
@@ -1135,8 +1135,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
: folge.state && folge.state.positionSeconds > 0
? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min`
: '';
const marken = markenwort(folge.markers);
const daneben = [nebenbei, marken].filter(Boolean).join(' · ');
const segmente = segmentwort(folge.markers);
const daneben = [nebenbei, segmente].filter(Boolean).join(' · ');
zeile.innerHTML =
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
+ (daneben ? `<span class="winzig">${esc(daneben)}</span>` : '');
+20 -20
View File
@@ -1,6 +1,6 @@
import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
mediathekMarken, type Markenumfang,
mediathekSegmente, type Segmentumfang,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api';
@@ -227,10 +227,10 @@ export function bibliotheksbereich(
// Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles,
// was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme
// über das Bild.
/** Startet einen Markenlauf und hängt die Standzeile daran. */
function markenlauf(umfang: Markenumfang): void {
/** Startet einen Segmentelauf und hängt die Standzeile daran. */
function segmentelauf(umfang: Segmentumfang): void {
laufendSetzen(true);
void mediathekMarken(aktuell.id, umfang).then(() => {
void mediathekSegmente(aktuell.id, umfang).then(() => {
hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
void verfolgen(false);
});
@@ -243,29 +243,29 @@ export function bibliotheksbereich(
// herauskam — und eben nicht die vierhundert, bei denen längst etwas
// steht. „Alles neu" ist die seltene und teure; sie gehört nicht
// hinter denselben Knopf.
const marken = knopf('Fehlende Marken suchen', 'btn');
marken.title = 'Nimmt sich die Titel vor, bei denen keine Marke steht.';
marken.onclick = () => nachfragen({
titel: `Fehlende Marken in „${aktuell.name}" suchen?`,
const segmente = knopf('Fehlende Segmente suchen', 'btn');
segmente.title = 'Nimmt sich die Titel vor, bei denen keine Marke steht.';
segmente.onclick = () => nachfragen({
titel: `Fehlende Segmente in „${aktuell.name}" suchen?`,
text: 'Nur, wo noch kein Vorspann oder Abspann steht. Bei einer Serie '
+ 'wird eine Staffel ganz gelesen, sobald einer ihrer Folgen etwas '
+ 'fehlt — der Tonabgleich vergleicht die Folgen miteinander und '
+ 'geht nicht einzeln. Es läuft im Hintergrund weiter.',
bestaetigen: 'Suchen',
tun: async () => markenlauf('Luecken'),
tun: async () => segmentelauf('Luecken'),
});
const markenAlle = knopf('Alles neu durchsuchen', 'btn');
markenAlle.title = 'Bestimmt auch vorhandene Marken noch einmal.';
markenAlle.onclick = () => nachfragen({
titel: `Alle Marken in „${aktuell.name}" neu bestimmen?`,
const segmenteAlle = knopf('Alles neu durchsuchen', 'btn');
segmenteAlle.title = 'Bestimmt auch vorhandene Segmente noch einmal.';
segmenteAlle.onclick = () => nachfragen({
titel: `Alle Segmente in „${aktuell.name}" neu bestimmen?`,
text: `Jeder Titel wird noch einmal durchsucht — auch die, bei denen `
+ `längst eine Marke steht. Der Server liest dafür von jeder Folge die `
+ `Tonspur und sieht sich bei jedem Film die letzten zwanzig Minuten an. `
+ `Bei ${aktuell.itemCount ?? 'vielen'} Titeln dauert das Stunden. `
+ `Für „was fehlt noch?" ist der Knopf daneben der richtige.`,
bestaetigen: 'Alles neu',
tun: async () => markenlauf('Alles'),
tun: async () => segmentelauf('Alles'),
});
const umbenennen = knopf('Umbenennen', 'btn');
@@ -335,13 +335,13 @@ export function bibliotheksbereich(
laufendSetzen(false);
stand.textContent = s.error ?? 'Gescheitert.';
return;
} else if (s.marken) {
// **Die Markensuche hängt sich hinten an und läuft weiter.**
} else if (s.segmente) {
// **Die Segmentesuche hängt sich hinten an und läuft weiter.**
// Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen
// Zweig stünde hier längst das Ergebnis des Scans, während der
// Server noch jede Folge durchhört.
laufendSetzen(true);
stand.textContent = s.marken;
stand.textContent = s.segmente;
}
// **Alle Zähler auf demselben Takt.**
@@ -357,9 +357,9 @@ export function bibliotheksbereich(
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
// Ein fertiger Scan ist nicht das Ende, solange die Markensuche
// Ein fertiger Scan ist nicht das Ende, solange die Segmentesuche
// dahinter noch läuft — sonst fiele die Zeile mitten im Lauf weg.
if (s.state === 'finished' && !s.marken) {
if (s.state === 'finished' && !s.segmente) {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
// was eine Nummer trägt, aber zu keiner Serie gehört.
@@ -402,7 +402,7 @@ export function bibliotheksbereich(
// und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, marken, markenAlle, pruefung, umbenennen,
knoepfe.append(los, auffrischen, segmente, segmenteAlle, pruefung, umbenennen,
zuruecksetzen, entfernen);
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
@@ -1,7 +1,7 @@
import {
doppelteZusammenlegen, servernameSetzen,
systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte,
bildspeicherLeeren, kratzplatzAufraeumen, type Wiedergabe,
bildspeicherLeeren, transcodeAufraeumen, type Wiedergabe,
} from '../../api';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog } from '../../dialog';
@@ -246,7 +246,7 @@ export function speicherbereich(): HTMLElement {
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
oben.appendChild(text);
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt nicht, ob
// **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. Die Dateizahl steht dabei, weil
// bei den Untertiteln die Anzahl das Problem ist und nicht die Grösse.
@@ -262,7 +262,7 @@ export function speicherbereich(): HTMLElement {
raeumen.onclick = async () => {
raeumen.disabled = true;
try {
const bericht = await kratzplatzAufraeumen();
const bericht = await transcodeAufraeumen();
await laden();
const gemeldet = document.createElement('p');
gemeldet.className = 'hinweis';
+3 -3
View File
@@ -8,7 +8,7 @@ import { abschnitt, zeile } from './teile';
/** Was der Server in Abständen von selbst tut.
*
* **Warum das eine eigene Seite ist.** Die drei Zahlen zum Kratzplatz
* **Warum das eine eigene Seite ist.** Die drei Zahlen zum Transcode-Cache
* standen zuerst unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr
* Thema. Sobald es eine Sicherung und ein Aufräumen der Datenbank gibt,
* gehören sie zusammen: alles hier ist etwas, das der Server ohne Zutun
@@ -61,9 +61,9 @@ export function wartungsbereich(): HTMLElement {
liste.innerHTML = '';
stand.forEach(s => liste.appendChild(aufgabe(s, werte)));
/* ---------- Der Kratzplatz ---------- */
/* ---------- Der Transcode-Cache ---------- */
liste.appendChild(abschnitt('Kratzplatz'));
liste.appendChild(abschnitt('Transcode-Cache'));
liste.appendChild(zeile(
'Obergrenze',