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:35:00 INFO Server gestartet — 1.0.0 · DEV
21:55:29 INFO Server fährt herunter. 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> /// <remarks>
/// <para> /// <para>
/// <c>Authorization: Bearer &lt;token&gt;</c>. Der Server hasht das Vorgezeigte /// <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. /// Datenbank und ist aus ihr auch nicht zu gewinnen.
/// </para> /// </para>
/// <para> /// <para>
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
var token = AusKopfzeile() ?? Request.Cookies[CookieName]; var token = AusKopfzeile() ?? Request.Cookies[CookieName];
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult(); if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
var fingerabdruck = Secrets.Fingerprint(token); var fingerfingerprint = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck); var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
var jetzt = clock.GetUtcNow(); var jetzt = clock.GetUtcNow();
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht."); 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) => string handle, FlaroContext db, TimeProvider clock) =>
{ {
var jetzt = clock.GetUtcNow(); var jetzt = clock.GetUtcNow();
var fingerabdruck = Secrets.Fingerprint(handle); var fingerfingerprint = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck); var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null) if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
return Results.Ok(new CodeState("expired", 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. /// lägen die Dateien woanders als die Datenbank sie vermutet.
/// </param> /// </param>
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</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="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> /// <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); int OptimizeDays, int ImageDays);
/// <param name="Teile"> /// <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 /// <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. /// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht. /// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
@@ -323,15 +323,15 @@ public sealed record ScanStatus(
int? Missing = null, int? Missing = null,
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary> /// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
string? Step = null, 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> /// <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 /// 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 /// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
/// laufend aus, und die Verwaltung käme nie zur Ruhe. /// laufend aus, und die Verwaltung käme nie zur Ruhe.
/// </remarks> /// </remarks>
string? Marken = null); string? Segmente = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary> /// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress( public sealed record MetadataProgress(
@@ -43,7 +43,7 @@ public static class EinladungEndpoints
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner."); return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert // **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 // Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht. // wiedergefunden, sondern neu gemacht.
var token = Secrets.Create(); var token = Secrets.Create();
@@ -121,7 +121,7 @@ public static class LibraryContentEndpoints
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]); 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 // **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht. // nichts; das Plakat der Serie sagt wenigstens, worum es geht.
@@ -138,24 +138,24 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(nachbar.Id), stände.GetValueOrDefault(nachbar.Id),
nachbar.PublishAt, nachbar.PublishAt,
SeriesId: diese.SeriesId, 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> /// <remarks>
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit /// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen. /// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
/// </remarks> /// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> MarkenZu( private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
FlaroContext db, IReadOnlyCollection<Guid> folgen) FlaroContext db, IReadOnlyCollection<Guid> folgen)
{ {
if (folgen.Count == 0) if (folgen.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2); 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)) .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. // die Spieler nehmen die erste passende.
.OrderBy(m => m.VonSekunden) .OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden }) .Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
@@ -164,24 +164,24 @@ public static class LibraryContentEndpoints
return roh.ToLookup( return roh.ToLookup(
m => m.Id, m => m.Id,
m => new MarkerResponse( 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> /// <summary>Dasselbe für Filme.</summary>
/// ///
/// <remarks> /// <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 /// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge /// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
/// mit derselben — unmöglich heute, aber nichts hält es fest. /// mit derselben — unmöglich heute, aber nichts hält es fest.
/// </remarks> /// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> FilmmarkenZu( private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
FlaroContext db, IReadOnlyCollection<Guid> filme) FlaroContext db, IReadOnlyCollection<Guid> filme)
{ {
if (filme.Count == 0) if (filme.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2); 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)) .Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.OrderBy(m => m.VonSekunden) .OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden }) .Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
@@ -190,7 +190,7 @@ public static class LibraryContentEndpoints
return roh.ToLookup( return roh.ToLookup(
m => m.Id, m => m.Id,
m => new MarkerResponse( 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) private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
@@ -424,20 +424,20 @@ public static class LibraryContentEndpoints
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", 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 // sich von Hand anstossen; ohne diese Zeile stünde die
// Verwaltung dann auf „idle", während der Server stundenlang // Verwaltung dann auf „idle", während der Server stundenlang
// arbeitet. // arbeitet.
var marken = erkennung.Stand(id)?.Text(); var segmente = erkennung.Stand(id)?.Text();
var zustand = koordinator.State(id); var zustand = koordinator.State(id);
if (zustand is null) if (zustand is null)
return Results.Ok(new ScanStatus( 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, null, null, null, null, null, null, null, null, null, null, null,
Marken: marken)); Segmente: segmente));
var bericht = zustand.Report; var bericht = zustand.Report;
var metadaten = zustand.Enrichment; var metadaten = zustand.Enrichment;
@@ -452,7 +452,7 @@ public static class LibraryContentEndpoints
metadaten.Skipped, metadaten.Error), metadaten.Skipped, metadaten.Error),
bericht?.Missing, bericht?.Missing,
zustand.Schritt?.Text(), zustand.Schritt?.Text(),
marken)); segmente));
}).WithName("Scanstand").RequireAuthorization(); }).WithName("Scanstand").RequireAuthorization();
} }
@@ -592,7 +592,7 @@ public static class LibraryContentEndpoints
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids); 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( var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
@@ -606,7 +606,7 @@ public static class LibraryContentEndpoints
SortTitle: m.SortTitleValue, SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned, SortTitlePinned: m.SortTitlePinned,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow), HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
Markers: marken[m.Id].ToList())) Markers: segmente[m.Id].ToList()))
.ToList(); .ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt)); return Results.Ok(new Page<MovieResponse>(items, gesamt));
@@ -802,7 +802,7 @@ public static class LibraryContentEndpoints
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null), ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
LibraryId: film.LibraryId, LibraryId: film.LibraryId,
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow), HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
Markers: (await FilmmarkenZu(db, [id]))[id].ToList())); Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async ( app.MapGet("/api/v1/series/{id:guid}", async (
@@ -870,7 +870,7 @@ public static class LibraryContentEndpoints
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [id]); 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, var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId) await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
@@ -883,7 +883,7 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(id), stände.GetValueOrDefault(id),
folge.PublishAt, folge.PublishAt,
SeriesId: folge.SeriesId, SeriesId: folge.SeriesId,
Markers: marken[id].ToList())); Markers: segmente[id].ToList()));
}).RequireAuthorization(); }).RequireAuthorization();
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist // **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. // Hintergrundlauf statt einer Antwort, auf die jemand wartet.
// Dasselbe für einen Film — dort sucht der Server im Bild. // Dasselbe für einen Film — dort sucht der Server im Bild.
app.MapPost("/api/v1/movies/{id:guid}/marken", async ( app.MapPost("/api/v1/movies/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, Guid id, FlaroContext db, Segmenterkennung erkennung,
Markenumfang umfang = Markenumfang.Alles) => Segmentumfang umfang = Segmentumfang.Alles) =>
{ {
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound(); 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 // Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
// bleiben. // bleiben.
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async ( 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() var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id) .Where(m => m.Id == id)
@@ -928,7 +928,7 @@ public static class LibraryContentEndpoints
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit 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 kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
var text = new StringBuilder("sekunde;gesamt;links;rechts\n"); var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
@@ -945,16 +945,16 @@ public static class LibraryContentEndpoints
"text/csv", $"markenkurve-{name}.csv"); "text/csv", $"markenkurve-{name}.csv");
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine Marke wieder wegnehmen.** // **Ein Segment wieder wegnehmen.**
// //
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal // Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas // recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
// dagegen tun ausser die Datenbank anzufassen. // dagegen tun ausser die Datenbank anzufassen.
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) => 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 // 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(); return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
@@ -963,14 +963,14 @@ public static class LibraryContentEndpoints
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm // unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
// die Antwort zu verweigern, die er haben will. // die Antwort zu verweigern, die er haben will.
app.MapPost("/api/v1/series/{id:guid}/marken", async ( app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, Guid id, FlaroContext db, Segmenterkennung erkennung,
Markenumfang umfang = Markenumfang.Alles) => Segmentumfang umfang = Segmentumfang.Alles) =>
{ {
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound(); if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
return erkennung.Starten(id, umfang) return erkennung.Starten(id, umfang)
? Results.Accepted() ? 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); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu // **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 // einmal. Der Lauf kann Stunden dauern und läuft deshalb im
// Hintergrund weiter; die Antwort kommt sofort. // Hintergrund weiter; die Antwort kommt sofort.
app.MapPost("/api/v1/libraries/{id:guid}/marken", async ( app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, Guid id, FlaroContext db, Segmenterkennung erkennung,
Markenumfang umfang = Markenumfang.Neue) => Segmentumfang umfang = Segmentumfang.Neue) =>
{ {
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id)) if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound(); return Results.NotFound();
@@ -1077,7 +1077,7 @@ public static class LibraryContentEndpoints
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids); 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() var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id) .Where(x => x.Id == id)
@@ -1091,7 +1091,7 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(f.Id), stände.GetValueOrDefault(f.Id),
f.PublishAt, f.PublishAt,
SeriesId: id, SeriesId: id,
Markers: marken[f.Id].ToList())).ToList(); Markers: segmente[f.Id].ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -106,14 +106,14 @@ public static class PlaybackEndpoints
// `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist // `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist
// immer die erste — dort ist die Handlung vorbei. // immer die erste — dort ist die Handlung vorbei.
var abspannAb = zustand.EpisodeId is { } folge var abspannAb = zustand.EpisodeId is { } folge
? await db.Marken.AsNoTracking() ? await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann) .Where(m => m.EpisodeId == folge && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden) .OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden) .Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
: zustand.MovieId is { } film : zustand.MovieId is { } film
? await db.Marken.AsNoTracking() ? await db.Segmente.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Markenart.Abspann) .Where(m => m.MovieId == film && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden) .OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden) .Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
@@ -213,10 +213,10 @@ public static class SettingsEndpoints
return Results.Ok(anlaesse); return Results.Ok(anlaesse);
}).RequireAuthorization(FlaroPolicies.Administrator); }).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 ( app.MapGet("/api/v1/settings/maintenance", async (
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) => Transcodeeinstellungen kratz, Wartungsplaneinstellungen plan) =>
{ {
var regeln = await kratz.RegelnAsync(); var regeln = await kratz.RegelnAsync();
var w = await plan.RegelnAsync(); var w = await plan.RegelnAsync();
@@ -230,23 +230,23 @@ public static class SettingsEndpoints
app.MapPut("/api/v1/settings/maintenance", async ( app.MapPut("/api/v1/settings/maintenance", async (
MaintenanceSettingsRequest anfrage, FlaroContext db, MaintenanceSettingsRequest anfrage, FlaroContext db,
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) => Transcodeeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
{ {
var vorher = await einstellungen.RegelnAsync(); var vorher = await einstellungen.RegelnAsync();
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld // Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
// kennt, soll die anderen nicht zurücksetzen. // kennt, soll die anderen nicht zurücksetzen.
var nachher = Kratzplatzregeln.Geprueft( var nachher = Transcoderegeln.Geprueft(
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024, anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays, anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes); anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
var zahl = System.Globalization.CultureInfo.InvariantCulture; var zahl = System.Globalization.CultureInfo.InvariantCulture;
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel, await Setzen(db, Transcodeeinstellungen.ObergrenzeSchluessel,
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl)); (nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel, await Setzen(db, Transcodeeinstellungen.UnbenutztSchluessel,
((int)nachher.Unbenutzt.TotalDays).ToString(zahl)); ((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel, await Setzen(db, Transcodeeinstellungen.SchonfristSchluessel,
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl)); ((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
var wVorher = await plan.RegelnAsync(); var wVorher = await plan.RegelnAsync();
var wNachher = Wartungsregeln.Geprueft( var wNachher = Wartungsregeln.Geprueft(
@@ -93,7 +93,7 @@ public static class StreamEndpoints
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.** // **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
// //
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein // 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 // Browser verlöre dabei das, was er von sich aus am besten
// kann: springen über Bereichsanfragen, ohne dass irgendwo // kann: springen über Bereichsanfragen, ohne dass irgendwo
// etwas neu gerechnet wird. // etwas neu gerechnet wird.
@@ -368,7 +368,7 @@ public static class StreamEndpoints
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis // **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den // 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) => app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
{ {
koordinator.Beenden(sitzung); koordinator.Beenden(sitzung);
@@ -251,12 +251,12 @@ public static class SystemEndpoints
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn // wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man // das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
// nachsieht — nicht der letzte. // 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) => Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad), new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null, 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 // nicht, ob das Segmente, umgepackte Filme oder Untertitel
// sind — und davon hängt ab, ob es ein Problem ist. // sind — und davon hängt ab, ob es ein Problem ist.
name == "Umpacken" ? pflege.Belegung() : null); name == "Umpacken" ? pflege.Belegung() : null);
@@ -269,7 +269,7 @@ public static class SystemEndpoints
}); });
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Kratzplatz aufräumen.** // **Den Transcode-Cache aufräumen.**
// //
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein // POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes // Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
@@ -301,8 +301,8 @@ public static class SystemEndpoints
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/storage/transcode/cleanup", async ( app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
Kratzplatzpflege pflege, CancellationToken ct) => Transcodepflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct))) Results.Ok(await pflege.LaufenAsync(Transcodepflege.Anlass.VonHand, ct)))
.RequireAuthorization(FlaroPolicies.Administrator); .RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.** // **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 // Umpacken ist der eine, in dem der Platz wirklich gebraucht
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt // wird — und `PlatzPruefen` sagt danach die Wahrheit statt
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen. // „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
await bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>() await bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken); .LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken);
PlatzPruefen(datei.SizeBytes); PlatzPruefen(datei.SizeBytes);
@@ -237,7 +237,7 @@ public sealed class PackageCoordinator(
if (platte.AvailableFreeSpace >= gebraucht) return; if (platte.AvailableFreeSpace >= gebraucht) return;
throw new InvalidOperationException( 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 " + $"{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."); $"{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. // und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment()); builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>(); builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<Schluesselbilder>(); builder.Services.AddSingleton<Keyframes>();
builder.Services.AddSingleton<FfmpegRemuxer>(); builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu // Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
@@ -172,11 +172,11 @@ builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>(); builder.Services.AddSingleton<ScanCoordinator>();
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den // Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
// Vermerk, welche Serie gerade abgetastet wird. // 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. // 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<Kapitelleser>();
builder.Services.AddSingleton<Markenerkennung>(); builder.Services.AddSingleton<Segmenterkennung>();
builder.Services.AddSingleton<PackageCoordinator>(); builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei // **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<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>(); builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>(); builder.Services.AddSingleton<Untertitelvorrat>();
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe. // Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Kratzplatzeinstellungen>(); builder.Services.AddScoped<Transcodeeinstellungen>();
builder.Services.AddSingleton<Kratzplatzpflege>(); builder.Services.AddSingleton<Transcodepflege>();
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt // **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand // 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 // Stromordner noch da und jedes Teilstück eines abgebrochenen
// Umpackens — und beim Start ist sicher, dass niemand davon noch // Umpackens — und beim Start ist sicher, dass niemand davon noch
// gebraucht wird. // gebraucht wird.
_ = app.Services.GetRequiredService<Kratzplatzpflege>() _ = app.Services.GetRequiredService<Transcodepflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping); .LaufenAsync(Transcodepflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Sehstandpflege>() _ = app.Services.GetRequiredService<Sehstandpflege>()
.NachUpdateAsync(leben.ApplicationStopping); .NachUpdateAsync(leben.ApplicationStopping);
+5 -5
View File
@@ -172,15 +172,15 @@ public sealed class ScanCoordinator(
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten // Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
// wandern, und die dazugehörigen Pakete und Untertitel gehören // wandern, und die dazugehörigen Pakete und Untertitel gehören
// zu einem Stand, den es nicht mehr gibt. // zu einem Stand, den es nicht mehr gibt.
_ = bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>() _ = bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan); .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- // 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; // hängt sich hinten an und nimmt sich Serie für Serie vor;
// gerechnet wird nur, wo noch nichts steht. // gerechnet wird nur, wo noch nichts steht.
_ = bereich.ServiceProvider.GetRequiredService<Markenerkennung>() _ = bereich.ServiceProvider.GetRequiredService<Segmenterkennung>()
.NachScanAsync(libraryId); .NachScanAsync(libraryId);
_zustand[libraryId] = _zustand[libraryId] with _zustand[libraryId] = _zustand[libraryId] with
@@ -7,14 +7,14 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api; namespace Flaro.Api;
/// <summary>Wie weit ein Markenlauf greift.</summary> /// <summary>Wie weit ein Segmentlauf greift.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <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 /// Dinge</b>, und genau dazwischen fehlte bisher die Unterscheidung. Ein
/// Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und /// 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. /// interessanteste Fall ist.
/// </para> /// </para>
/// <para> /// <para>
@@ -23,18 +23,18 @@ namespace Flaro.Api;
/// steht. /// steht.
/// </para> /// </para>
/// </remarks> /// </remarks>
public enum Markenumfang public enum Segmentumfang
{ {
/// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary> /// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary>
Neue, Neue,
/// <summary> /// <summary>
/// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon /// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon
/// eine Marke steht. /// ein Segment steht.
/// </summary> /// </summary>
Luecken, Luecken,
/// <summary>Alles, auch was längst eine Marke hat.</summary> /// <summary>Alles, auch was längst ein Segment hat.</summary>
Alles, Alles,
} }
@@ -43,7 +43,7 @@ public enum Markenumfang
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b> /// <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 /// teilen — was dabei herauskommt, entscheidet allein, welchen Ausschnitt
/// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann. /// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann.
/// Eine zweite Erkennung dafür zu schreiben hiesse, dieselbe Suche zweimal /// 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. /// Zeitlimit zu laufen und am Ende nichts zu haben.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class Markenerkennung( public sealed class Segmenterkennung(
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
TimeProvider clock, TimeProvider clock,
ILogger<Markenerkennung> log) ILogger<Segmenterkennung> log)
{ {
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary> /// <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 /// gleich wieder weggeworfen — gefunden, verworfen, und am Ende stand
/// kein Knopf da, obwohl die Erkennung gearbeitet hatte. /// 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. /// mehr als dieses Fenster zu erlauben hiesse nichts zu erlauben.
/// </remarks> /// </remarks>
public const double Hoechstabspann = Abspannfenster; public const double Hoechstabspann = Abspannfenster;
@@ -121,7 +121,7 @@ public sealed class Markenerkennung(
/// <para> /// <para>
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der /// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die /// <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. /// liesse einen beendeten Scan wieder laufend aussehen.
/// </para> /// </para>
/// <para> /// <para>
@@ -136,7 +136,7 @@ public sealed class Markenerkennung(
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null; _stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary> /// <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; if (!_laufend.TryAdd(serieId, true)) return false;
@@ -148,7 +148,7 @@ public sealed class Markenerkennung(
} }
catch (Exception fehler) 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)); serieId, Fehlertext.FuerMenschen(fehler));
} }
finally finally
@@ -159,12 +159,12 @@ public sealed class Markenerkennung(
return true; 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> /// <remarks>
/// <para> /// <para>
/// <b>Hier, weil es sonst nie passiert.</b> Eine neue Folge bekommt /// <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 /// nicht weiss, dass es etwas zu fragen gibt. Der Scan ist der
/// Augenblick, in dem neue Folgen entstehen. /// Augenblick, in dem neue Folgen entstehen.
/// </para> /// </para>
@@ -177,12 +177,12 @@ public sealed class Markenerkennung(
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task NachScanAsync( public async Task NachScanAsync(
Guid mediathek, Markenumfang umfang = Markenumfang.Neue, Guid mediathek, Segmentumfang umfang = Segmentumfang.Neue,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var raum = scopes.CreateScope(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>(); var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var mediathekname = await db.Libraries.AsNoTracking() var mediathekname = await db.Libraries.AsNoTracking()
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct) .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> /// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
private async Task SerienAsync( private async Task SerienAsync(
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien, 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() var folgen = await db.Episodes.AsNoTracking()
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct); .CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
log.LogInformation( 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); + "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
var marken = 0; var segmente = 0;
var ohneFund = 0; var ohneFund = 0;
var angefasst = 0; var angefasst = 0;
@@ -253,16 +253,16 @@ public sealed class Markenerkennung(
void Melden(Scanschritt schritt) => void Melden(Scanschritt schritt) =>
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count }; _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; if (!_laufend.TryAdd(id, true)) continue;
try try
{ {
var stand = await LaufenAsync(id, umfang, ct, Melden); var stand = await LaufenAsync(id, umfang, ct, Melden);
marken += stand.Marken; segmente += stand.Segmente;
if (!stand.Angefasst) continue; if (!stand.Angefasst) continue;
angefasst++; angefasst++;
if (stand.Marken == 0) ohneFund++; if (stand.Segmente == 0) ohneFund++;
} }
catch (Exception fehler) catch (Exception fehler)
{ {
@@ -270,16 +270,16 @@ public sealed class Markenerkennung(
// `Message` an, und bei EF Core steht dort „See the inner // `Message` an, und bei EF Core steht dort „See the inner
// exception for details." — eine Zeile, die ausdrücklich // exception for details." — eine Zeile, die ausdrücklich
// sagt, dass sie die Auskunft nicht enthält. // 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)); titel, Fehlertext.FuerMenschen(fehler));
} }
finally { _laufend.TryRemove(id, out _); } finally { _laufend.TryRemove(id, out _); }
} }
log.LogInformation( 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.", + "{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> /// <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, /// dauert das Stunden; eine Anfrage so lange offen zu halten hiesse,
/// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im /// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine /// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
/// Marke steht. /// Segment steht.
/// </remarks> /// </remarks>
public void MediathekStarten(Guid mediathek, Markenumfang umfang) public void MediathekStarten(Guid mediathek, Segmentumfang umfang)
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try { await NachScanAsync(mediathek, umfang, CancellationToken.None); } try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
catch (Exception fehler) 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)); mediathek, Fehlertext.FuerMenschen(fehler));
} }
}); });
@@ -312,7 +312,7 @@ public sealed class Markenerkennung(
/// läuft dann ins Leere, und das kostet eine Abfrage. /// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks> /// </remarks>
private async Task FilmeAsync( 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) CancellationToken ct, Action<Scanschritt> melden)
{ {
// **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem // **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); var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
abfrage = umfang switch abfrage = umfang switch
{ {
Markenumfang.Alles => abfrage, Segmentumfang.Alles => abfrage,
// Die Lücke: gesucht wurde vielleicht, gefunden nichts. // Die Lücke: gesucht wurde vielleicht, gefunden nichts.
Markenumfang.Luecken => abfrage.Where(m => Segmentumfang.Luecken => abfrage.Where(m =>
!db.Marken.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)), !db.Segmente.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
_ => abfrage.Where(m => _ => 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 var offen = await abfrage
@@ -346,7 +346,7 @@ public sealed class Markenerkennung(
var film = offen[i]; var film = offen[i];
var titel = Filmname(film.Title, film.Year); 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++; } try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; }
catch (Exception fehler) catch (Exception fehler)
@@ -380,16 +380,16 @@ public sealed class Markenerkennung(
/// <para> /// <para>
/// <b>Erst grob, dann zielen.</b> Ein Überblick über die letzten zwanzig /// <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 /// 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. /// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task<bool> FilmAsync( public async Task<bool> FilmAsync(
Guid filmId, Markenumfang umfang, CancellationToken ct = default) Guid filmId, Segmentumfang umfang, CancellationToken ct = default)
{ {
using var raum = scopes.CreateScope(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); 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 kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var film = await db.Movies.AsNoTracking() var film = await db.Movies.AsNoTracking()
@@ -416,21 +416,21 @@ public sealed class Markenerkennung(
return false; return false;
} }
if (umfang == Markenumfang.Luecken && await db.Marken.AsNoTracking() if (umfang == Segmentumfang.Luecken && await db.Segmente.AsNoTracking()
.AnyAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct)) .AnyAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct))
{ {
log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel); log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel);
return false; return false;
} }
if (umfang == Markenumfang.Neue && await db.Markensuchen.AsNoTracking() if (umfang == Segmentumfang.Neue && await db.Segmentsuchen.AsNoTracking()
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct)) .AnyAsync(x => x.MovieId == filmId && x.Art == Segmentart.Abspann, ct))
{ {
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel); log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel);
return false; return false;
} }
await VermerkenAsync(db, filmId, Markenart.Abspann, ct); await VermerkenAsync(db, filmId, Segmentart.Abspann, ct);
var gefunden = await BildabspannAsync( var gefunden = await BildabspannAsync(
db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path, db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
@@ -438,12 +438,12 @@ public sealed class Markenerkennung(
if (!gefunden) 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 // nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
// Fehlgriff für immer stehen. // Fehlgriff für immer stehen.
var alt = await db.Marken var alt = await db.Segmente
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct); .FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct);
if (alt is not null) db.Marken.Remove(alt); if (alt is not null) db.Segmente.Remove(alt);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -456,12 +456,12 @@ public sealed class Markenerkennung(
/// <para> /// <para>
/// <b>Dunkel und bis zum Ende durchhalten</b> — mehr braucht es nicht, /// <b>Dunkel und bis zum Ende durchhalten</b> — mehr braucht es nicht,
/// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober /// 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 /// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll
/// kurzer Sonden für die Kante. /// kurzer Sonden für die Kante.
/// </para> /// </para>
/// <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 /// davon, dass der Abspann in jeder Folge derselbe ist — und genau das
/// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik, /// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik,
/// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus /// 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. /// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param> /// </param>
private async Task<bool> BildabspannAsync( 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, Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
CancellationToken ct) CancellationToken ct)
{ {
@@ -482,7 +482,7 @@ public sealed class Markenerkennung(
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet // **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit // 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 // Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei
// „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff // „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff
// Surprise". // Surprise".
@@ -492,7 +492,7 @@ public sealed class Markenerkennung(
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.", log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(ausKapitel)); name, TimeSpan.FromSeconds(ausKapitel));
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann, return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann,
ausKapitel, laufzeit, ct); ausKapitel, laufzeit, ct);
} }
@@ -500,7 +500,7 @@ public sealed class Markenerkennung(
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct); var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt" // **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 // 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. // steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
var stücke = Abspannkante.Suchen(proben, regeln, out var grund); 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 // **Nur die erste Kante wird nachgemessen.** Sie ist die, die der
// Zuschauer zuerst sieht, und die, an der „gilt als gesehen" hängt. // 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. // fünf bis zehn Sekunden genau — dafür lohnt keine Sonde.
var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab; var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync( var kante = await Abspannkante.VerfeinernAsync(
@@ -545,28 +545,28 @@ public sealed class Markenerkennung(
(a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}"))); (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> /// <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"> /// <param name="Angefasst">
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon /// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht /// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
/// erst gefragt, und beides in einen Topf zu werfen machte die /// erst gefragt, und beides in einen Topf zu werfen machte die
/// Zusammenfassung wertlos. /// Zusammenfassung wertlos.
/// </param> /// </param>
private readonly record struct Serienstand(int Marken, bool Angefasst); private readonly record struct Serienstand(int Segmente, bool Angefasst);
private async Task<Serienstand> LaufenAsync( private async Task<Serienstand> LaufenAsync(
Guid serieId, Markenumfang umfang, CancellationToken ct, Guid serieId, Segmentumfang umfang, CancellationToken ct,
Action<Scanschritt>? melden = null) Action<Scanschritt>? melden = null)
{ {
using var raum = scopes.CreateScope(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>(); var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>(); var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>(); var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var serienname = await db.Series.AsNoTracking() var serienname = await db.Series.AsNoTracking()
@@ -595,14 +595,14 @@ public sealed class Markenerkennung(
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.** // **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
// Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst // Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst
// bei jedem Scan aus wie eine, in der noch niemand nachgesehen hat. // 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) .Where(x => x.SeriesId == serieId)
.ToListAsync(ct); .ToListAsync(ct);
// **Und was tatsächlich an den Folgen hängt.** Für „nur die // **Und was tatsächlich an den Folgen hängt.** Für „nur die
// Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde, // Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde,
// nicht ob etwas herauskam. // nicht ob etwas herauskam.
var vorhanden = await db.Marken.AsNoTracking() var vorhanden = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null .Where(m => m.EpisodeId != null
&& db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId)) && db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId))
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art }) .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).", log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
serienname, staffeln.Count, folgen.Count); 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. // ihre Vorspänne bekommen hat — und das ist die häufigere Frage.
var gefundenIntro = 0; var gefundenIntro = 0;
var gefundenAbspann = 0; var gefundenAbspann = 0;
@@ -629,25 +629,25 @@ public sealed class Markenerkennung(
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht // **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
// springen — es liest immer von vorn. Also die ganze Spur je // springen — es liest immer von vorn. Also die ganze Spur je
// Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte // 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. // billiger als zweimal lesen.
// Eine Art fehlt dieser Staffel, sobald irgendeine ihrer Folgen // 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. // 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)); f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art));
var fehlt = new[] { Markenart.Intro, Markenart.Abspann } var fehlt = new[] { Segmentart.Intro, Segmentart.Abspann }
.Where(art => umfang == Markenumfang.Alles .Where(art => umfang == Segmentumfang.Alles
|| gesucht.FirstOrDefault( || gesucht.FirstOrDefault(
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|| lauf.Folgen != geordnet.Count || lauf.Folgen != geordnet.Count
|| (umfang == Markenumfang.Luecken && Luecke(art))) || (umfang == Segmentumfang.Luecken && Luecke(art)))
.ToList(); .ToList();
if (fehlt.Count == 0) continue; if (fehlt.Count == 0) continue;
angefasst = true; 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 // Staffel mit einer einzigen Folge — eine laufende Serie, von
// der erst die Premiere da ist — hat das nicht. Früher wurde // der erst die Premiere da ist — hat das nicht. Früher wurde
// sie hier übersprungen, und zwar ganz; seit es den Bildweg // 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++) for (var i = 0; i < geordnet.Count; i++)
{ {
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i))); melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i)));
var abdruck = await leser.LesenAsync(geordnet[i].Path, ct); var fingerprint = await leser.LesenAsync(geordnet[i].Path, ct);
if (abdruck.Count > 0) ganze[geordnet[i].Id] = abdruck; if (fingerprint.Count > 0) ganze[geordnet[i].Id] = fingerprint;
} }
} }
else else
{ {
log.LogInformation( 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); + "es bleibt das Bild.", serienname, staffel.Key);
} }
@@ -675,7 +675,7 @@ public sealed class Markenerkennung(
foreach (var art in tonarten) 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>(); var fensteranfang = new Dictionary<Guid, double>();
foreach (var folge in geordnet) foreach (var folge in geordnet)
@@ -685,21 +685,21 @@ public sealed class Markenerkennung(
var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count); var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count);
if (anzahl < 2) continue; if (anzahl < 2) continue;
abdruecke.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList())); fingerprints.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList()));
fensteranfang[folge.Id] = vonIndex * Tonabdruckleser.SekundenJeAbdruck; fensteranfang[folge.Id] = vonIndex * Fingerprintleser.SekundenJeFingerprint;
} }
if (abdruecke.Count < 2) continue; if (fingerprints.Count < 2) continue;
var treffer = Tonabgleich.ReiheAbgleichen( var treffer = Fingerprintabgleich.ReiheAbgleichen(
abdruecke, Tonabdruckleser.SekundenJeAbdruck); fingerprints, Fingerprintleser.SekundenJeFingerprint);
// **Vermerken, dass gesucht wurde — vor dem Ergebnis.** // **Vermerken, dass gesucht wurde — vor dem Ergebnis.**
// Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu // Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu
// vermerken hiesse, ihn beim nächsten Scan zu wiederholen. // vermerken hiesse, ihn beim nächsten Scan zu wiederholen.
await VermerkenAsync(db, serieId, staffel.Key, art, geordnet.Count, ct); 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) foreach (var (folgenId, stelle) in treffer)
{ {
@@ -713,18 +713,18 @@ public sealed class Markenerkennung(
if (!await SchreibenAsync(db, folgenId, istFilm: false, art, if (!await SchreibenAsync(db, folgenId, istFilm: false, art,
stelle.Von + versatz, stelle.Bis + versatz, ct)) continue; 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 nummern = geordnet.Select(f => f.Id).ToList();
var ueberBild = new HashSet<Guid>(); 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. // 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); geordnet.Count, ct);
// **Was der Ton nicht fand, sucht das Bild.** // **Was der Ton nicht fand, sucht das Bild.**
@@ -737,13 +737,13 @@ public sealed class Markenerkennung(
// andere Folge. // andere Folge.
// //
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig // **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, // 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); await db.SaveChangesAsync(ct);
var mitAbspann = await db.Marken.AsNoTracking() var mitAbspann = await db.Segmente.AsNoTracking()
.Where(m => m.Art == Markenart.Abspann .Where(m => m.Art == Segmentart.Abspann
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value)) && m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => m.EpisodeId!.Value) .Select(m => m.EpisodeId!.Value)
.ToListAsync(ct); .ToListAsync(ct);
@@ -754,7 +754,7 @@ public sealed class Markenerkennung(
if (mitAbspann.Contains(folge.Id)) continue; if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) 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, if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct)) istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
@@ -805,13 +805,13 @@ public sealed class Markenerkennung(
CancellationToken ct) CancellationToken ct)
{ {
var nummern = folgen.Select(f => f.Id).ToList(); 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)) .Where(m => m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden }) .Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync(ct); .ToListAsync(ct);
var mitIntro = marken.Count(m => m.Art == Markenart.Intro); var mitIntro = segmente.Count(m => m.Art == Segmentart.Intro);
var mitAbspann = marken.Count(m => m.Art == Markenart.Abspann); var mitAbspann = segmente.Count(m => m.Art == Segmentart.Abspann);
log.LogInformation( log.LogInformation(
// **Jeder Platzhalter verbraucht ein Argument, auch ein // **Jeder Platzhalter verbraucht ein Argument, auch ein
@@ -825,8 +825,8 @@ public sealed class Markenerkennung(
foreach (var (id, name) in folgen) foreach (var (id, name) in folgen)
{ {
var intro = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Intro); var intro = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Intro);
var abspann = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Abspann); var abspann = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Abspann);
log.LogInformation("{Folge}: {Vorspann}, {Abspann}.", log.LogInformation("{Folge}: {Vorspann}, {Abspann}.",
name, name,
@@ -863,35 +863,35 @@ public sealed class Markenerkennung(
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}"; return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
} }
/// <summary>Welcher Ausschnitt des Abdrucks abgetastet wird.</summary> /// <summary>Welcher Ausschnitt des Fingerprints abgetastet wird.</summary>
/// ///
/// <remarks> /// <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 /// 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> /// </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( var fenster = (int)Math.Round(
(art == Markenart.Intro ? Introfenster : Abspannfenster) (art == Segmentart.Intro ? Introfenster : Abspannfenster)
/ Tonabdruckleser.SekundenJeAbdruck); / Fingerprintleser.SekundenJeFingerprint);
if (fenster >= werte) return (0, werte); 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> /// <summary>Hält fest, dass bei diesem Film gesucht wurde.</summary>
private async Task VermerkenAsync( 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) .FirstOrDefault(x => x.MovieId == filmId && x.Art == art)
?? await db.Markensuchen ?? await db.Segmentsuchen
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct); .FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; } if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
db.Markensuchen.Add(new Markensuche db.Segmentsuchen.Add(new Segmentsuche
{ {
MovieId = filmId, MovieId = filmId,
Art = art, Art = art,
@@ -902,20 +902,20 @@ public sealed class Markenerkennung(
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary> /// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
private async Task VermerkenAsync( 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.** // **Erst im Änderungsspeicher nachsehen, dann in der Datenbank.**
// //
// Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt // Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt
// und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird // und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird
// für dieselbe Staffel zweimal vermerkt — einmal nach dem // 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 // nichts, legte eine zweite Zeile an und brach beim Speichern am
// eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in // eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in
// diesem Lauf gefunden worden war. // 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) .FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art)
?? await db.Markensuchen ?? await db.Segmentsuchen
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct); x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
@@ -926,7 +926,7 @@ public sealed class Markenerkennung(
return; return;
} }
db.Markensuchen.Add(new Markensuche db.Segmentsuchen.Add(new Segmentsuche
{ {
SeriesId = serieId, SeriesId = serieId,
Staffel = staffel, Staffel = staffel,
@@ -937,11 +937,11 @@ public sealed class Markenerkennung(
} }
private Task<bool> SchreibenAsync( 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) => double von, double bis, CancellationToken ct) =>
SchreibenAsync(db, titelId, istFilm, art, [new Abspannstück(von, bis)], 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> /// <remarks>
/// <para> /// <para>
@@ -955,24 +955,24 @@ public sealed class Markenerkennung(
/// <para> /// <para>
/// <b>In derselben Transaktion</b> — gespeichert wird weiter oben, und /// <b>In derselben Transaktion</b> — gespeichert wird weiter oben, und
/// bis dahin stehen Löschung und Anlage zusammen. Dazwischen gibt es /// 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> /// </para>
/// </remarks> /// </remarks>
private async Task<bool> SchreibenAsync( 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) IReadOnlyList<Abspannstück> stücke, CancellationToken ct)
{ {
if (stücke.Count == 0) return false; if (stücke.Count == 0) return false;
var alte = istFilm var alte = istFilm
? await db.Marken.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct) ? await db.Segmente.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.EpisodeId == titelId && m.Art == art).ToListAsync(ct);
db.Marken.RemoveRange(alte); db.Segmente.RemoveRange(alte);
foreach (var stück in stücke) foreach (var stück in stücke)
{ {
db.Marken.Add(new Marke db.Segmente.Add(new Segment
{ {
EpisodeId = istFilm ? null : titelId, EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null, MovieId = istFilm ? titelId : null,
+11 -11
View File
@@ -32,7 +32,7 @@ public sealed record Stromsitzung(
/// <remarks> /// <remarks>
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt /// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und /// 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> /// </remarks>
public DateTimeOffset Gesehen { get; set; } = Begonnen; public DateTimeOffset Gesehen { get; set; } = Begonnen;
@@ -61,7 +61,7 @@ public sealed class Streamkoordinator(
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
FlaroPaths pfade, FlaroPaths pfade,
FfmpegOptions ffmpeg, FfmpegOptions ffmpeg,
Schluesselbilder schluesselbilder, Keyframes schluesselbilder,
TimeProvider clock, TimeProvider clock,
ILogger<Streamkoordinator> log) : IDisposable ILogger<Streamkoordinator> log) : IDisposable
{ {
@@ -140,22 +140,22 @@ public sealed class Streamkoordinator(
var stufe = await bereich.ServiceProvider var stufe = await bereich.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct); .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 // 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 // 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 // Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
// Bild, und die Uhr des Spielers zwei Sekunden daneben. // Bild, und die Uhr des Spielers zwei Sekunden daneben.
// //
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und // Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not // schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
// ein Stück zurückzuspringen. // 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 // 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 // fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
// keiner Rechnung hier. // keiner Rechnung hier.
// //
@@ -188,14 +188,14 @@ public sealed class Streamkoordinator(
// **Mit Zeiten, und auch im Tagesprotokoll.** // **Mit Zeiten, und auch im Tagesprotokoll.**
// //
// „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen // „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 // Start von ffmpeg und das erste Segment, und keine dieser Strecken
// war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm — // war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm —
// sie ist genau das, wonach man im Protokoll sucht. // sie ist genau das, wonach man im Protokoll sucht.
var meldung = var meldung =
$"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s" $"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s"
+ (plan.Hoehe is { } h ? $", auf {h}p" : "") + (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); 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 /// 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 /// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer
/// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein /// 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. /// bekommt alle paar Sekunden ein Segment geschrieben.
/// </remarks> /// </remarks>
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt) public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
@@ -521,7 +521,7 @@ public sealed class Streamkoordinator(
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende // **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu // 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(); WurzelLeeren();
} }
} }
@@ -4,22 +4,22 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api; 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> /// <remarks>
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je /// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine /// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
/// Migration. /// Migration.
/// </remarks> /// </remarks>
public sealed class Kratzplatzeinstellungen(FlaroContext db) public sealed class Transcodeeinstellungen(FlaroContext db)
{ {
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb"; public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage"; public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten"; 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; if (_gemerkt is { } fertig) return fertig;
@@ -29,9 +29,9 @@ public sealed class Kratzplatzeinstellungen(FlaroContext db)
|| e.Key == SchonfristSchluessel) || e.Key == SchonfristSchluessel)
.ToDictionaryAsync(e => e.Key, e => e.Value, ct); .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), Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024),
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays), (int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
(int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes)); (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> /// <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); 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> /// <remarks>
/// <para> /// <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. /// nebeneinander löschten einander die Dateien unter den Händen weg.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class Kratzplatzpflege( public sealed class Transcodepflege(
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
FlaroPaths pfade, FlaroPaths pfade,
PackageCoordinator pakete, PackageCoordinator pakete,
Streamkoordinator stroeme, Streamkoordinator stroeme,
TimeProvider uhr, TimeProvider uhr,
ILogger<Kratzplatzpflege> log) ILogger<Transcodepflege> log)
{ {
private readonly SemaphoreSlim _einerZurZeit = new(1, 1); private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
@@ -78,7 +78,7 @@ public sealed class Kratzplatzpflege(
if (bericht.Frei > 0) if (bericht.Frei > 0)
{ {
log.LogInformation( 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); anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
} }
@@ -100,7 +100,7 @@ public sealed class Kratzplatzpflege(
using var raum = scopes.CreateScope(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider var regeln = await raum.ServiceProvider
.GetRequiredService<Kratzplatzeinstellungen>().RegelnAsync(ct); .GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen, // **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene // 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 }) .Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct); .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)) akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
{ {
frei += Loeschen(a.Pfad); frei += Loeschen(a.Pfad);
+1 -1
View File
@@ -12,7 +12,7 @@ namespace Flaro.Core.Auth;
/// </para> /// </para>
/// <para> /// <para>
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein, /// <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 /// 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 /// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
/// Mediathek. /// Mediathek.
+1 -1
View File
@@ -14,7 +14,7 @@ namespace Flaro.Core.Auth;
/// </para> /// </para>
/// <para> /// <para>
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei /// <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 /// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
/// Datenbank liest, kann daraus keine Einladung bauen. /// Datenbank liest, kann daraus keine Einladung bauen.
/// </para> /// </para>
+2 -2
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Auth;
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary> /// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
/// ///
/// <remarks> /// <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 /// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im /// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles. /// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
@@ -23,7 +23,7 @@ public static class Secrets
public static string Create() => public static string Create() =>
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes)); 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) => public static string Fingerprint(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret))); Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Delivery;
/// ///
/// <remarks> /// <remarks>
/// <para> /// <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, /// 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, /// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
/// alles zu behalten oder alles wegzuwerfen. /// 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="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="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> /// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
public sealed record Kratzplatzregeln( public sealed record Transcoderegeln(
long ObergrenzeBytes, long ObergrenzeBytes,
TimeSpan Unbenutzt, TimeSpan Unbenutzt,
TimeSpan Schonfrist) TimeSpan Schonfrist)
@@ -34,7 +34,7 @@ public sealed record Kratzplatzregeln(
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen /// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
/// Neustart, der den Benutzungszähler verliert. /// Neustart, der den Benutzungszähler verliert.
/// </remarks> /// </remarks>
public static Kratzplatzregeln Vorgabe { get; } = public static Transcoderegeln Vorgabe { get; } =
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30)); new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary> /// <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 /// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
/// gelten als „aus". /// gelten als „aus".
/// </remarks> /// </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), new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)), unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440))); 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"> /// <param name="Benutzt">
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst. /// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
@@ -66,7 +66,7 @@ public readonly record struct Paketakte(
Paketname.Teile? Name); Paketname.Teile? Name);
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary> /// <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="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
/// <param name="bauart">Wie der Server heute baut.</param> /// <param name="bauart">Wie der Server heute baut.</param>
@@ -75,7 +75,7 @@ public static class Kratzplatzauswahl
IReadOnlyList<Paketakte> akten, IReadOnlyList<Paketakte> akten,
IReadOnlyDictionary<Guid, long> bekannt, IReadOnlyDictionary<Guid, long> bekannt,
int bauart, int bauart,
Kratzplatzregeln regeln, Transcoderegeln regeln,
DateTimeOffset jetzt, DateTimeOffset jetzt,
Func<string, bool> inBenutzung) Func<string, bool> inBenutzung)
{ {
@@ -124,8 +124,8 @@ public sealed class Stichwortlink
public Guid? SeriesId { get; init; } public Guid? SeriesId { get; init; }
} }
/// <summary>Was für ein Abschnitt eine Marke bezeichnet.</summary> /// <summary>Was für ein Abschnitt ein Segment bezeichnet.</summary>
public enum Markenart public enum Segmentart
{ {
/// <summary>Der Vorspann.</summary> /// <summary>Der Vorspann.</summary>
Intro, Intro,
@@ -148,7 +148,7 @@ public enum Markenart
/// im Film ist dieselbe. /// im Film ist dieselbe.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class Marke public sealed class Segment
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary> /// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary>
@@ -162,7 +162,7 @@ public sealed class Marke
/// </remarks> /// </remarks>
public Guid? EpisodeId { get; init; } public Guid? EpisodeId { get; init; }
public Guid? MovieId { 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 VonSekunden { get; set; }
public required double BisSekunden { get; set; } public required double BisSekunden { get; set; }
@@ -195,21 +195,21 @@ public sealed class Marke
/// Lauf. /// Lauf.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class Markensuche public sealed class Segmentsuche
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Eine Staffel einer Serie — oder ein Film.</summary> /// <summary>Eine Staffel einer Serie — oder ein Film.</summary>
/// ///
/// <remarks> /// <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. /// Staffel steht die Nummer daneben; ein Film hat keine.
/// </remarks> /// </remarks>
public Guid? SeriesId { get; init; } public Guid? SeriesId { get; init; }
public int? Staffel { get; init; } public int? Staffel { get; init; }
public Guid? MovieId { 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> /// <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 /// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
/// Verwaltung minutenlang so aus, als täte sich nichts. /// Verwaltung minutenlang so aus, als täte sich nichts.
/// </remarks> /// </remarks>
Marken, Segmente,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary> /// <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.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}", Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen", 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, Scanphase.Vermerk => Was,
_ => Was, _ => Was,
}; };
@@ -18,7 +18,7 @@ namespace Flaro.Core.Library;
/// <para> /// <para>
/// <b>Transcode-Zwischenstände gehören ausdrücklich nicht hierher.</b> Die /// <b>Transcode-Zwischenstände gehören ausdrücklich nicht hierher.</b> Die
/// liegen unter <c>FLARO_TRANSCODE</c>, auf einem eigenen Volume. Ein /// liegen unter <c>FLARO_TRANSCODE</c>, auf einem eigenen Volume. Ein
/// Medienordner ist kein Kratzplatz. /// Medienordner ist kein Transcode-Cache.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class SidecarNames 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="Sekunde">Die Stelle im Film.</param>
/// <param name="Dunkelanteil">Wie viel Prozent des Bildes dunkel sind, 0 bis 100.</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> /// <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 /// <b>In Sekunden, nicht in Proben</b> — dieselbe Lehre wie bei
/// <see cref="Schlusstafel"/>, hier eine Zeile höher übersehen. Als /// <see cref="Schlusstafel"/>, hier eine Zeile höher übersehen. Als
/// Probenzahl hing die Nachsicht daran, wie dicht die Datei ihre /// 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 — /// 1318 Proben auf zwanzig Minuten, also eine alle 0,9 Sekunden —
/// zwei Proben waren dort 1,8 Sekunden Nachsicht. Bei einer Datei mit /// 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. /// Zugeständnis, zehnfach verschieden.
/// </para> /// </para>
/// </remarks> /// </remarks>
@@ -94,7 +94,7 @@ public readonly record struct Abspannregeln
/// <remarks> /// <remarks>
/// <b>In Sekunden, nicht in Proben.</b> Eine Logotafel dauert, was sie /// <b>In Sekunden, nicht in Proben.</b> Eine Logotafel dauert, was sie
/// dauert; wie viele Messpunkte darauf fallen, hängt allein daran, wie /// 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 /// dieselbe Tafel bei der einen Datei verziehen und bei der nächsten
/// ein Abbruch. /// ein Abbruch.
/// </remarks> /// </remarks>
@@ -156,7 +156,7 @@ public readonly record struct Abspannregeln
/// <b>Reine Rechnung, ohne Datei.</b> Das Abtasten braucht ffmpeg; das /// <b>Reine Rechnung, ohne Datei.</b> Das Abtasten braucht ffmpeg; das
/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen, /// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen,
/// ohne einen Film vorzuhalten — dieselbe Naht wie bei /// ohne einen Film vorzuhalten — dieselbe Naht wie bei
/// <see cref="Tonabgleich"/>. /// <see cref="Fingerprintabgleich"/>.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class Abspannkante public static class Abspannkante
@@ -165,10 +165,10 @@ public static class Abspannkante
/// ///
/// <param name="proben"> /// <param name="proben">
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen /// 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> /// </param>
public static IReadOnlyList<Abspannstück> Suchen( public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) => IReadOnlyList<Frameprobe> proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _); Suchen(proben, regeln, out _);
/// <param name="grund"> /// <param name="grund">
@@ -178,7 +178,7 @@ public static class Abspannkante
/// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten. /// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten.
/// </param> /// </param>
public static IReadOnlyList<Abspannstück> Suchen( public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln, out string grund) IReadOnlyList<Frameprobe> proben, Abspannregeln regeln, out string grund)
{ {
grund = "gefunden"; grund = "gefunden";
if (proben.Count < 2) if (proben.Count < 2)
@@ -244,11 +244,11 @@ public static class Abspannkante
/// <remarks> /// <remarks>
/// <b>Die Nachsicht zählt Sekunden, nicht Proben.</b> Eine Schrifttafel /// <b>Die Nachsicht zählt Sekunden, nicht Proben.</b> Eine Schrifttafel
/// blitzt auf, ein Logo steht zwei Sekunden im Bild; wie viele /// 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. /// der Datei und schwankt um eine Zehnerpotenz.
/// </remarks> /// </remarks>
private static List<Abspannstück> Läufe( private static List<Abspannstück> Läufe(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) IReadOnlyList<Frameprobe> proben, Abspannregeln regeln)
{ {
var läufe = new List<Abspannstück>(); var läufe = new List<Abspannstück>();
double? von = null; 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> /// <summary>Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.</summary>
public static double? HellDavor( public static double? HellDavor(
IReadOnlyList<Bildprobe> proben, double dunkelAb, Abspannregeln regeln) IReadOnlyList<Frameprobe> proben, double dunkelAb, Abspannregeln regeln)
{ {
double? letzteHelle = null; double? letzteHelle = null;
foreach (var p in proben) foreach (var p in proben)
@@ -297,7 +297,7 @@ public static class Abspannkante
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf.</b> Die /// <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 /// Aufnahme fünf bis zehn Sekunden auseinander. Den ganzen Bereich fein
/// abzutasten hiesse, ihn zu dekodieren; zehn Sonden von je einer /// abzutasten hiesse, ihn zu dekodieren; zehn Sonden von je einer
/// Sekunde kosten einen Bruchteil davon. Denselben Weg geht Jellyfins /// 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>
/// <para> /// <para>
/// <b>Verglichen werden Tonabdrücke, keine Töne.</b> Was hereinkommt, ist je /// <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 /// den Chromaprint aus dem Ton rechnet. Zwei Aufnahmen desselben Stücks
/// ergeben darin fast dieselben Zahlen, auch bei anderer Lautstärke, anderem /// ergeben darin fast dieselben Zahlen, auch bei anderer Lautstärke, anderem
/// Codec und anderer Bitrate. Deshalb wird nicht auf Gleichheit geprüft, /// 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. /// sich das Schwierige prüfen, ohne eine Mediendatei vorzuhalten.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class Tonabgleich public static class Fingerprintabgleich
{ {
/// <summary>Wie viele Bits zweier Abdrücke sich unterscheiden dürfen.</summary> /// <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> /// <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 /// Wie viel Zeit eine Zeitscheibe abdeckt. Chromaprint rechnet mit
/// 11025 Hz und 4096 Abtastwerten bei zwei Dritteln Überlappung — das /// 11025 Hz und 4096 Abtastwerten bei zwei Dritteln Überlappung — das
/// sind rund 0,1238 Sekunden. /// sind rund 0,1238 Sekunden.
@@ -75,14 +75,14 @@ public static class Tonabgleich
public static Gemeinsamkeit? Finden( public static Gemeinsamkeit? Finden(
IReadOnlyList<uint> a, IReadOnlyList<uint> a,
IReadOnlyList<uint> b, IReadOnlyList<uint> b,
double sekundenJeAbdruck, double sekundenJeFingerprint,
double mindestlänge = StandardMindestlänge, double mindestlänge = StandardMindestlänge,
int bitabstand = StandardBitabstand, int bitabstand = StandardBitabstand,
int lücke = StandardLücke) 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; if (mindestens <= 0) mindestens = 1;
Gemeinsamkeit? beste = null; Gemeinsamkeit? beste = null;
@@ -90,7 +90,7 @@ public static class Tonabgleich
// **Jede Verschiebung durchprobieren.** Das Intro steht in beiden // **Jede Verschiebung durchprobieren.** Das Intro steht in beiden
// Folgen an einer anderen Stelle; gesucht ist die Verschiebung, bei // Folgen an einer anderen Stelle; gesucht ist die Verschiebung, bei
// der die meisten Zeitscheiben aufeinanderpassen. Bei zwei Minuten // 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. // gegen das Lesen der Datei, aus der sie stammen.
for (var versatz = -(b.Count - 1); versatz < a.Count; versatz++) 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); var lauf = LängsterLauf(a, b, vonA, vonB, länge, bitabstand, lücke);
if (lauf is not var (start, dauer) || dauer < mindestens) continue; 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( beste = new Gemeinsamkeit(
(vonA + start) * sekundenJeAbdruck, (vonA + start) * sekundenJeFingerprint,
(vonA + start + dauer) * sekundenJeAbdruck, (vonA + start + dauer) * sekundenJeFingerprint,
(vonB + start) * sekundenJeAbdruck, (vonB + start) * sekundenJeFingerprint,
(vonB + start + dauer) * sekundenJeAbdruck); (vonB + start + dauer) * sekundenJeFingerprint);
} }
return beste is not null && beste.Laenge >= mindestlänge ? beste : null; return beste is not null && beste.Laenge >= mindestlänge ? beste : null;
@@ -176,8 +176,8 @@ public static class Tonabgleich
/// </para> /// </para>
/// </remarks> /// </remarks>
public static IReadOnlyDictionary<TSchlüssel, (double Von, double Bis)> ReiheAbgleichen<TSchlüssel>( public static IReadOnlyDictionary<TSchlüssel, (double Von, double Bis)> ReiheAbgleichen<TSchlüssel>(
IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList<uint> Abdruck)> folgen, IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList<uint> Fingerprint)> folgen,
double sekundenJeAbdruck, double sekundenJeFingerprint,
double mindestlänge = StandardMindestlänge) double mindestlänge = StandardMindestlänge)
where TSchlüssel : notnull where TSchlüssel : notnull
{ {
@@ -186,10 +186,10 @@ public static class Tonabgleich
for (var i = 0; i + 1 < folgen.Count; i++) for (var i = 0; i + 1 < folgen.Count; i++)
{ {
var (schlüsselA, abdruckA) = folgen[i]; var (schlüsselA, fingerprintA) = folgen[i];
var (schlüsselB, abdruckB) = folgen[i + 1]; 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; if (treffer is null) continue;
// Der erste Fund je Folge gewinnt: er stammt vom Vergleich mit // Der erste Fund je Folge gewinnt: er stammt vom Vergleich mit
@@ -79,8 +79,8 @@ public static class ProgressRule
var schwelle = laenge * regeln.GesehenAnteil; var schwelle = laenge * regeln.GesehenAnteil;
// Der Abspann zieht die Schwelle nach vorn, nie nach hinten: eine // Der Abspann zieht die Schwelle nach vorn, nie nach hinten: eine
// falsch erkannte Marke bei Minute vierzig darf nicht dazu führen, // falsch erkannte Segment bei Minute vierzig darf nicht dazu führen,
// dass eine Folge erst nach der Marke als gesehen gilt. // dass eine Folge erst nach dem Segment als gesehen gilt.
if (abspannAb is { } ab && ab > TimeSpan.Zero && ab < schwelle) schwelle = ab; if (abspannAb is { } ab && ab > TimeSpan.Zero && ab < schwelle) schwelle = ab;
return new Outcome(stelle, Watched: stelle >= schwelle); 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<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>(); public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>(); public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Marke> Marken => Set<Marke>(); public DbSet<Segment> Segmente => Set<Segment>();
public DbSet<Markensuche> Markensuchen => Set<Markensuche>(); public DbSet<Segmentsuche> Segmentsuchen => Set<Segmentsuche>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>(); public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>(); public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>(); public DbSet<Person> People => Set<Person>();
@@ -141,7 +141,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
{ {
e.HasKey(x => x.Id); 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. // zwei Links niemals dasselbe Konto füttern können.
e.HasIndex(x => x.TokenFingerprint).IsUnique(); e.HasIndex(x => x.TokenFingerprint).IsUnique();
e.Property(x => x.TokenFingerprint).HasMaxLength(64); 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); e.Property(x => x.Anzeige).HasMaxLength(32);
}); });
b.Entity<Marke>(e => b.Entity<Segment>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
// **Mehrere Stücke je Art, und das ist der ganze Zweck.** Hier // **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>(); e.Property(x => x.Art).HasConversion<int>();
}); });
b.Entity<Markensuche>(e => b.Entity<Segmentsuche>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique() 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"); 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 => modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -788,6 +718,76 @@ namespace Flaro.Data.Migrations
b.ToTable("People"); 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 => modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1201,32 +1201,6 @@ namespace Flaro.Data.Migrations
.OnDelete(DeleteBehavior.Cascade); .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 => modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{ {
b.HasOne("Flaro.Core.Libraries.Library", null) b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -1278,6 +1252,32 @@ namespace Flaro.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{ {
b.HasOne("Flaro.Core.Libraries.Library", null) b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -4,12 +4,12 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media; namespace Flaro.Media;
/// <summary>Holt den Tonabdruck eines Dateiabschnitts.</summary> /// <summary>Holt den Fingerprint eines Dateiabschnitts.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Mit <c>fpcalc</c> aus Chromaprint, nicht von Hand.</b> Was hier /// <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 /// wiedererkennt, auch bei anderem Codec und anderer Lautstärke. Das ist
/// Signalverarbeitung mit einer Fourier-Transformation darin; sie noch /// Signalverarbeitung mit einer Fourier-Transformation darin; sie noch
/// einmal zu schreiben hiesse, einen seit zwanzig Jahren erprobten Algorith- /// 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. /// trifft Jellyfins Intro Skipper, und aus demselben Grund.
/// </para> /// </para>
/// <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 /// (<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. /// Zugabe, kein Teil der Wiedergabe.
/// </para> /// </para>
/// <para> /// <para>
@@ -29,20 +29,20 @@ namespace Flaro.Media;
/// Gelegenheiten für falsche Funde. /// Gelegenheiten für falsche Funde.
/// </para> /// </para>
/// </remarks> /// </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> /// <remarks>
/// Chromaprint rechnet mit 11025 Hz, 4096 Abtastwerten je Scheibe und /// Chromaprint rechnet mit 11025 Hz, 4096 Abtastwerten je Scheibe und
/// zwei Dritteln Überlappung: 4096/3 / 11025 Sekunden. Die Zahl steht /// 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>
/// <remarks> /// <remarks>
/// Nachgerechnet an einer Datei von 2700 Sekunden: 21 786 Werte, also /// Nachgerechnet an einer Datei von 2700 Sekunden: 21 786 Werte, also
/// 0,12393 Sekunden je Wert. Die Formel trifft es auf vier Stellen. /// 0,12393 Sekunden je Wert. Die Formel trifft es auf vier Stellen.
/// </remarks> /// </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> /// <summary>Wo <c>fpcalc</c> liegt — überschreibbar wie ffmpeg selbst.</summary>
public static string Programm => 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> /// <remarks>
/// <para> /// <para>
@@ -104,7 +104,7 @@ public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
{ {
if (!await VorhandenAsync(ct)) 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 []; return [];
} }
@@ -127,7 +127,7 @@ public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct); using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Grosszügig: bei einem Film auf einer schlafenden Platte dauert // Grosszügig: bei einem Film auf einer schlafenden Platte dauert
// allein das Anlaufen Sekunden. Knapp bemessen hiesse, bei jedem // 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)); frist.CancelAfter(TimeSpan.FromMinutes(3));
try try
@@ -12,7 +12,7 @@ namespace Flaro.Media;
/// <para> /// <para>
/// <b>Der einzige Teil der Abspannsuche, der ffmpeg anfasst.</b> Was /// <b>Der einzige Teil der Abspannsuche, der ffmpeg anfasst.</b> Was
/// daraus folgt, rechnet <see cref="Abspannkante"/> ohne Datei — dieselbe /// 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>
/// <para> /// <para>
/// <b>Zwei Griffe, weil zwei Fragen.</b> „Gibt es am Ende überhaupt eine /// <b>Zwei Griffe, weil zwei Fragen.</b> „Gibt es am Ende überhaupt eine
@@ -23,7 +23,7 @@ namespace Flaro.Media;
/// heisst zehn Sekunden Ungenauigkeit. /// heisst zehn Sekunden Ungenauigkeit.
/// </para> /// </para>
/// </remarks> /// </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> /// <summary>Auf welche Breite verkleinert wird, bevor gerechnet wird.</summary>
/// ///
@@ -53,21 +53,21 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// </remarks> /// </remarks>
private const string Dunkelschwelle = "24"; 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> /// <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 /// 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 /// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke
/// zu finden, zu wenig für ihre Kante. Die holt /// zu finden, zu wenig für ihre Kante. Die holt
/// <see cref="DunkelanteilAsync"/> nach. /// <see cref="DunkelanteilAsync"/> nach.
/// </remarks> /// </remarks>
public async Task<IReadOnlyList<Bildprobe>> UeberblickAsync( public async Task<IReadOnlyList<Frameprobe>> UeberblickAsync(
string pfad, double ab, double dauer, CancellationToken ct = default) string pfad, double ab, double dauer, CancellationToken ct = default)
{ {
var ausgabe = await LaufenAsync( 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); 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> /// <summary>Der mittlere Dunkelanteil einer einzelnen Sekunde.</summary>
/// ///
/// <remarks> /// <remarks>
/// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst /// Alle Bilder, nicht nur Keyframes: eine Sekunde enthält sonst
/// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Schlüsselbild /// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Keyframe
/// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind /// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind
/// das zehn Sekunden Bildmaterial, also Millisekunden. /// das zehn Sekunden Bildmaterial, also Millisekunden.
/// </remarks> /// </remarks>
@@ -84,7 +84,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
string pfad, double bei, CancellationToken ct = default) string pfad, double bei, CancellationToken ct = default)
{ {
var ausgabe = await LaufenAsync( 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); TimeSpan.FromSeconds(60), ct);
var proben = Lesen(ausgabe, 0); 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 /// Kette schrieben denselben Metadatenschlüssel und überschrieben
/// einander; mit <c>split</c> ginge es, kostete aber drei Ausgabewege /// einander; mit <c>split</c> ginge es, kostete aber drei Ausgabewege
/// und eine Zusammenführung. Für etwas, das jemand einmal je Film von /// 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. /// Geschäft.
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task<IReadOnlyList<Kurvenpunkt>> KurveAsync( public async Task<IReadOnlyList<Kurvenpunkt>> KurveAsync(
string pfad, double ab, double dauer, CancellationToken ct = default) string pfad, double ab, double dauer, CancellationToken ct = default)
{ {
async Task<IReadOnlyList<Bildprobe>> Lauf(string? zuschnitt) => Lesen( async Task<IReadOnlyList<Frameprobe>> Lauf(string? zuschnitt) => Lesen(
await LaufenAsync(pfad, ab, dauer, nurSchluesselbilder: true, await LaufenAsync(pfad, ab, dauer, nurKeyframes: true,
TimeSpan.FromMinutes(10), ct, zuschnitt), TimeSpan.FromMinutes(10), ct, zuschnitt),
ab); 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"); var rechts = await Lauf("crop=iw*0.2:ih:iw*0.8:0");
// Nach Zeit zusammenführen: die drei Läufe sehen dieselben // Nach Zeit zusammenführen: die drei Läufe sehen dieselben
// Schlüsselbilder, aber darauf zu bauen wäre eine Annahme. // Keyframes, aber darauf zu bauen wäre eine Annahme.
double BeiZeit(IReadOnlyList<Bildprobe> reihe, double sekunde) => double BeiZeit(IReadOnlyList<Frameprobe> reihe, double sekunde) =>
reihe.Count == 0 ? double.NaN reihe.Count == 0 ? double.NaN
: reihe.MinBy(p => Math.Abs(p.Sekunde - sekunde)).Dunkelanteil; : 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( 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) TimeSpan frist, CancellationToken ct, string? zuschnitt = null)
{ {
var start = new ProcessStartInfo(optionen.Ffmpeg) 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("-v");
start.ArgumentList.Add("error"); start.ArgumentList.Add("error");
if (nurSchluesselbilder) if (nurKeyframes)
{ {
start.ArgumentList.Add("-skip_frame"); start.ArgumentList.Add("-skip_frame");
start.ArgumentList.Add("nokey"); 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 /// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem
/// Dateianfang — deshalb der Aufschlag. /// Dateianfang — deshalb der Aufschlag.
/// </remarks> /// </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; double? zeit = null;
foreach (var zeile in ausgabe.Split('\n')) 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, && double.TryParse(w.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var wert)) CultureInfo.InvariantCulture, out var wert))
{ {
proben.Add(new Bildprobe(versatz + sekunde, wert)); proben.Add(new Frameprobe(versatz + sekunde, wert));
zeit = null; zeit = null;
} }
} }
+8 -8
View File
@@ -70,10 +70,10 @@ public static class HlsArgumente
/// </remarks> /// </remarks>
public const int SegmentSekunden = 6; 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> /// <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 /// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die
/// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht. /// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht.
/// </remarks> /// </remarks>
@@ -109,17 +109,17 @@ public static class HlsArgumente
// **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.** // **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.**
// //
// Genau gesprungen schneidet ffmpeg den Ton auf die Sekunde, das // 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 // anfangen. Dazwischen liegt Bild ohne Ton, und jeder Spieler darf
// diese Lücke deuten, wie er will; hls.js schiebt den Ton nach // 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, // Sekunden: bei einem Sprung auf 17 begann der Ton erst bei 1,96,
// bei 15,014 sogar erst bei 4,98. // 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 // anfangen. Dieselben Sprünge, gemessen: 0,004 und 0,015 — und
// zwar bei *jedem* Wert, nicht nur bei gut getroffenen. Das ist // 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. // die Millisekunde stimmen, sonst wird es schlimmer als vorher.
// //
// Beim Umkodieren nicht: dort wird das Bild neu gerechnet und // Beim Umkodieren nicht: dort wird das Bild neu gerechnet und
@@ -222,7 +222,7 @@ public static class HlsArgumente
/// <remarks> /// <remarks>
/// <b>Sechs Nachkommastellen, nicht drei.</b> Auf drei gerundet kann ein /// <b>Sechs Nachkommastellen, nicht drei.</b> Auf drei gerundet kann ein
/// Sprungpunkt um eine Millisekunde zu klein werden, und eine Millisekunde /// 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 /// davor. Gemessen: bei 15,015 begann der Ton nach 0,004 s, bei 15,014
/// nach 4,98 s. /// nach 4,98 s.
/// </remarks> /// </remarks>
@@ -286,7 +286,7 @@ public static class HlsArgumente
"-level", "4.1", "-level", "4.1",
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
// Feste Schlüsselbildabstände, damit jedes Segment gleich lang wird. // Feste Keyframeabstände, damit jedes Segment gleich lang wird.
"-g", schluesselabstand, "-g", schluesselabstand,
"-keyint_min", schluesselabstand, "-keyint_min", schluesselabstand,
"-sc_threshold", "0", "-sc_threshold", "0",
+5 -5
View File
@@ -7,7 +7,7 @@ using Flaro.Core.Media;
namespace Flaro.Media; namespace Flaro.Media;
/// <summary>Liest die Kapitelmarken einer Datei.</summary> /// <summary>Liest die Kapitelsegmente einer Datei.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -25,7 +25,7 @@ namespace Flaro.Media;
/// </para> /// </para>
/// <para> /// <para>
/// <b>Billig.</b> ffprobe liest dafür nur den Kopf der Datei, kein Bild. /// <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> /// </para>
/// </remarks> /// </remarks>
public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> log) public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> log)
@@ -75,7 +75,7 @@ public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> l
/// ///
/// <remarks> /// <remarks>
/// Getrennt vom Aufruf, damit es sich ohne ffprobe prüfen lässt — wie /// 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> /// </remarks>
public static IReadOnlyList<Kapitel> Lesen(string json) 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, "start_time", out var von)) continue;
if (!Zeit(eintrag, "end_time", out var bis)) bis = von; if (!Zeit(eintrag, "end_time", out var bis)) bis = von;
var titel = eintrag.TryGetProperty("tags", out var marken) var titel = eintrag.TryGetProperty("tags", out var segmente)
&& marken.TryGetProperty("title", out var name) && segmente.TryGetProperty("title", out var name)
? name.GetString() ?? "" ? name.GetString() ?? ""
: ""; : "";
@@ -4,18 +4,18 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media; 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> /// <remarks>
/// <para> /// <para>
/// <b>Warum das überhaupt nötig ist.</b> Beim Umpacken wird das Bild Byte für /// <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 /// Byte übernommen, und ein kopierter Strom lässt sich nicht mitten in einer
/// Bildgruppe aufschneiden. Wer bei Sekunde 754 einsteigt, bekommt deshalb /// 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. /// fängt genau bei 754 an.
/// </para> /// </para>
/// <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 /// 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 /// bei 1,98 — zwei Sekunden stummes Bild am Anfang. Aneinander ausgerichtet
/// waren beide dabei richtig; falsch war, was der Server dem Spieler über den /// waren beide dabei richtig; falsch war, was der Server dem Spieler über den
@@ -24,13 +24,13 @@ namespace Flaro.Media;
/// </para> /// </para>
/// <para> /// <para>
/// <b>Also wird gerundet statt geraten.</b> Der Sprung geht auf das /// <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 /// 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, /// Uhr stimmt. Dass man ein paar Sekunden früher einsteigt als gewünscht,
/// fällt beim Weiterschauen niemandem auf — ein Tonversatz schon. /// fällt beim Weiterschauen niemandem auf — ein Tonversatz schon.
/// </para> /// </para>
/// </remarks> /// </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> /// <summary>Wie weit vor dem Wunsch gesucht wird.</summary>
/// ///
@@ -46,14 +46,14 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
/// ///
/// <remarks> /// <remarks>
/// <para> /// <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 /// 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 /// 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 /// des Fensters auf Paketebene ab, und das letzte Bild darin kam gar
/// nicht mehr heraus. /// nicht mehr heraus.
/// </para> /// </para>
/// <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: /// gegen die Stelle, an der ffmpeg tatsächlich anfängt:
/// </para> /// </para>
/// <code> /// <code>
@@ -72,7 +72,7 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
/// </remarks> /// </remarks>
private const double Nachlauf = 10; 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) public async Task<double> DavorAsync(string pfad, double ab, CancellationToken ct = default)
{ {
if (ab <= 0) return 0; if (ab <= 0) return 0;
@@ -86,7 +86,7 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
{ {
"-v", "error", "-v", "error",
"-select_streams", "v:0", "-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. // einzelne Bild der halben Minute durch.
"-skip_frame", "nokey", "-skip_frame", "nokey",
"-show_entries", "frame=pts_time", "-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 // Dauert das Suchen länger als das Lesen der Datei erlaubt, ist der
// Wunsch die bessere Antwort als gar keine. // 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; return ab;
} }
catch (Exception fehler) when (fehler is IOException or InvalidOperationException) 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; return ab;
} }
} }
@@ -220,7 +220,7 @@ public class EinladungTests
Assert.That(einladung.Token, Is.Not.Empty); Assert.That(einladung.Token, Is.Not.Empty);
Assert.That(roh, Does.Not.Contain(einladung.Token), 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> /// <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> /// <remarks>
/// <b>Sparsam benutzen.</b> Ein Test, der am Server vorbei schreibt, /// <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: /// prüft nicht mehr, ob der Server es könnte. Für Segmente ist es nötig:
/// sie entstehen aus einem Tonabgleich, und der braucht Mediendateien /// sie entstehen aus einem Fingerprintabgleich, und der braucht Mediendateien
/// mit echtem Ton, die es hier nicht gibt. /// mit echtem Ton, die es hier nicht gibt.
/// </remarks> /// </remarks>
public async Task InDerDatenbank(Func<FlaroContext, Task> tun) 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> /// <summary>Vorspann und Abspann auf dem Weg zu den Spielern.</summary>
/// ///
/// <remarks> /// <remarks>
/// <b>Ohne Tonabgleich.</b> Ob er findet, was er finden soll, steht in /// <b>Ohne Fingerprintabgleich.</b> Ob er findet, was er finden soll, steht in
/// <c>TonabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien. /// <c>FingerprintabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
/// Hier geht es um das Stück danach: dass eine gefundene Marke auch bei dem /// Hier geht es um das Stück danach: dass eine gefundene Segment auch bei dem
/// ankommt, der den Knopf zeichnen soll. /// ankommt, der den Knopf zeichnen soll.
/// </remarks> /// </remarks>
public class MarkenTests public class SegmentTests
{ {
private string _bestand = ""; private string _bestand = "";
@@ -67,7 +67,7 @@ public class MarkenTests
} }
[Test] [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"); Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
@@ -77,14 +77,14 @@ public class MarkenTests
await server.InDerDatenbank(async db => 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, 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, VonSekunden = 2500, BisSekunden = 2600, ErkanntAm = DateTimeOffset.UtcNow,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -105,7 +105,7 @@ public class MarkenTests
/// <summary>Auch in der Folgenliste — dort holt die App sie beim Blättern.</summary> /// <summary>Auch in der Folgenliste — dort holt die App sie beim Blättern.</summary>
[Test] [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 - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
@@ -116,9 +116,9 @@ public class MarkenTests
await server.InDerDatenbank(async db => 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, VonSekunden = 10, BisSekunden = 40, ErkanntAm = DateTimeOffset.UtcNow,
}); });
await db.SaveChangesAsync(); 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, Has.Count.EqualTo(1));
Assert.That(liste.Items.Single(f => f.Id != folge).Markers, Is.Empty, 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] [Test]
public async Task Ohne_Erkennung_ist_die_Liste_leer() 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(); (await client.PostAsync($"/api/v1/series/{serie}/marken", null)).EnsureSuccessStatusCode();
// Der Lauf hängt im Hintergrund; ohne fpcalc ist er sofort durch. // 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++) for (var versuch = 0; versuch < 40; versuch++)
{ {
await server.InDerDatenbank(async db => await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.AsNoTracking() vermerke = await db.Segmentsuchen.AsNoTracking()
.Where(x => x.SeriesId == serie).ToListAsync()); .Where(x => x.SeriesId == serie).ToListAsync());
if (vermerke.Count > 0) break; if (vermerke.Count > 0) break;
await Task.Delay(50); 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] [Test]
public async Task Ein_Film_bekommt_seine_Marke() public async Task Ein_Film_bekommt_seine_Segment()
{ {
Datei("Matrix (1999)/Matrix (1999).mkv"); Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -230,9 +230,9 @@ public class MarkenTests
await server.InDerDatenbank(async db => 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, VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
}); });
await db.SaveChangesAsync(); 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> /// <remarks>
/// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal recht /// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal recht
@@ -257,7 +257,7 @@ public class MarkenTests
/// tun ausser die Datenbank anzufassen. /// tun ausser die Datenbank anzufassen.
/// </remarks> /// </remarks>
[Test] [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"); Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -268,14 +268,14 @@ public class MarkenTests
await server.InDerDatenbank(async db => 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, 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, GelaufenAm = DateTimeOffset.UtcNow,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -286,14 +286,14 @@ public class MarkenTests
var vermerke = 0; var vermerke = 0;
await server.InDerDatenbank(async db => 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.Multiple(() =>
{ {
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach!.Markers, Is.Empty); Assert.That(danach!.Markers, Is.Empty);
Assert.That(vermerke, Is.EqualTo(1), 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> /// <remarks>
/// <para> /// <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 /// 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. /// Lauf erledigt, obwohl er der interessanteste Fall ist.
/// </para> /// </para>
/// <para> /// <para>
@@ -313,9 +313,9 @@ public class MarkenTests
/// </para> /// </para>
/// </remarks> /// </remarks>
[TestCase("Neue", false, TestName = "Neue überspringt, was schon durchsucht ist")] [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")] [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) string umfang, bool erwartetAngefasst)
{ {
Datei("Matrix (1999)/Matrix (1999).mkv"); Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -325,7 +325,7 @@ public class MarkenTests
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"); var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id; 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); var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db => await server.InDerDatenbank(async db =>
{ {
@@ -334,9 +334,9 @@ public class MarkenTests
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film); var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120); 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(); await db.SaveChangesAsync();
}); });
@@ -348,7 +348,7 @@ public class MarkenTests
for (var versuch = 0; versuch < 40; versuch++) for (var versuch = 0; versuch < 40; versuch++)
{ {
await server.InDerDatenbank(async db => 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()); .Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
if (gelaufen != vorher) break; if (gelaufen != vorher) break;
await Task.Delay(50); await Task.Delay(50);
@@ -360,9 +360,9 @@ public class MarkenTests
: "dieser Umfang muss ihn überspringen"); : "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] [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"); Datei("Matrix (1999)/Matrix (1999).mkv");
@@ -377,14 +377,14 @@ public class MarkenTests
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film); var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120); 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, 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(); await db.SaveChangesAsync();
}); });
@@ -395,10 +395,10 @@ public class MarkenTests
var gelaufen = DateTimeOffset.MinValue; var gelaufen = DateTimeOffset.MinValue;
await server.InDerDatenbank(async db => 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()); .Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
Assert.That(gelaufen, Is.EqualTo(vorher), 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. /// Strom fängt bei null an, der Film bei Minute 30.
/// </para> /// </para>
/// <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 /// auf „genau zwei Sekunden" und hielt damit einen Fehler fest. Ein
/// kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe /// 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 /// 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. /// also ist null die richtige Antwort.
/// </para> /// </para>
/// </remarks> /// </remarks>
@@ -353,10 +353,10 @@ public class StreamTests
Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2), Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2),
"Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film."); "Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film.");
Assert.That(strom.StartSeconds, Is.EqualTo(0), 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] [Test]
public async Task Schliessen_raeumt_die_Sitzung_weg() public async Task Schliessen_raeumt_die_Sitzung_weg()
{ {
@@ -501,7 +501,7 @@ public class StreamTests
/// <para> /// <para>
/// <b>Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem /// <b>Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem
/// gewünschten</b> — und das ist keine Bequemlichkeit. Beim Kopieren /// 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 /// vier Sekunden ist das die Null. Der Untertitel muss zu dem passen, was
/// wirklich läuft. /// wirklich läuft.
/// </para> /// </para>
@@ -6,7 +6,7 @@ using Flaro.Core.Delivery;
namespace Flaro.Api.Tests; 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> /// <remarks>
/// <para> /// <para>
@@ -17,13 +17,13 @@ namespace Flaro.Api.Tests;
/// <para> /// <para>
/// <b>Hier wird die Verdrahtung geprüft</b>, nicht die Regel — die steht /// <b>Hier wird die Verdrahtung geprüft</b>, nicht die Regel — die steht
/// ohne Platte und ohne Server in /// 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 /// Aufräumer die Dateien, erkennt er, was zur Mediathek gehört, und lässt
/// er das Gültige stehen. /// er das Gültige stehen.
/// </para> /// </para>
/// </remarks> /// </remarks>
[TestFixture] [TestFixture]
public sealed class KratzplatzTests public sealed class TranscodeTests
{ {
private string _bestand = ""; private string _bestand = "";
@@ -41,8 +41,8 @@ public sealed class KratzplatzTests
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ } try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
} }
/// <summary>Ein Server mit einem eingelesenen Film und bekanntem Kratzplatz.</summary> /// <summary>Ein Server mit einem eingelesenen Film und bekanntem Transcode-Cache.</summary>
private async Task<(HttpClient Client, string Kratzplatz, Guid DateiId)> Aufgesetzt(FlaroServer server) private async Task<(HttpClient Client, string Ordner, Guid DateiId)> Aufgesetzt(FlaroServer server)
{ {
var voll = Path.Combine(_bestand, "Probe (2026)", "Probe (2026).mkv"); var voll = Path.Combine(_bestand, "Probe (2026)", "Probe (2026).mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!); Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
@@ -244,18 +244,18 @@ public sealed class KratzplatzTests
Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart), kilobyte: 8); Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart), kilobyte: 8);
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage"); 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.Multiple(() =>
{ {
Assert.That(kratzplatz.Teile, Is.Not.Null); Assert.That(transcode.Teile, Is.Not.Null);
Assert.That(kratzplatz.Teile!.Select(t => t.Name), Assert.That(transcode.Teile!.Select(t => t.Name),
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" })); 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.Dateien, Is.EqualTo(1));
Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners"); 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, 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!.BackupDays, Is.EqualTo(1));
Assert.That(nachher.BackupKeep, Is.EqualTo(10)); Assert.That(nachher.BackupKeep, Is.EqualTo(10));
Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt"); 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> /// <summary>Eine Reihe von Messpunkten im Abstand von zehn Sekunden.</summary>
/// ///
/// <param name="abspannAb">Ab wann dunkel. <c>null</c> heisst: nie.</param> /// <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) 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; return proben;
} }
@@ -71,7 +71,7 @@ public class AbspannkanteTests
/// <summary>Eine Szene nach dem Abspann nimmt den Fund weg.</summary> /// <summary>Eine Szene nach dem Abspann nimmt den Fund weg.</summary>
/// ///
/// <remarks> /// <remarks>
/// Lieber keine Marke als eine, die über die Szene hinwegspringt. /// Lieber kein Segment als eine, die über die Szene hinwegspringt.
/// </remarks> /// </remarks>
[Test] [Test]
public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg() public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg()
@@ -223,14 +223,14 @@ public class AbspannAnteilTests
/// ///
/// <remarks> /// <remarks>
/// Zwanzig Minuten vor dem Ende einer Folge von fünfundvierzig, an den /// 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> /// </remarks>
private static List<Bildprobe> Fenster( private static List<Frameprobe> Fenster(
double abspannAb, double imAbspann, double imInhalt = 0) 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) 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; return proben;
} }
@@ -300,13 +300,13 @@ public class AbspannSchlusstafelTests
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) => private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von; 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) for (var t = 1500.0; t <= 2700; t += 5)
{ {
var dunkel = t >= tafelAb ? 0 : t < abspannAb ? 0 : 85; var dunkel = t >= tafelAb ? 0 : t < abspannAb ? 0 : 85;
proben.Add(new Bildprobe(t, dunkel)); proben.Add(new Frameprobe(t, dunkel));
} }
return proben; return proben;
} }
@@ -336,20 +336,20 @@ public class AbspannSchlusstafelTests
} }
} }
/// <summary>Dieselbe Szene, zweierlei Schlüsselbilddichte.</summary> /// <summary>Dieselbe Szene, zweierlei Keyframedichte.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Der Fehler, für den diese Prüfungen stehen.</b> Die Nachsicht /// <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> /// gegenüber hellen Stellen mitten im Abspann war in <i>Proben</i>
/// angegeben, nicht in Sekunden. Was das bedeutet, hängt allein daran, /// 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. /// eine Zehnerpotenz.
/// </para> /// </para>
/// <para> /// <para>
/// Gemessen am Bestand: „(T)Raumschiff Surprise" liefert 1318 Proben auf /// Gemessen am Bestand: „(T)Raumschiff Surprise" liefert 1318 Proben auf
/// zwanzig Minuten, also eine alle 0,9 Sekunden. Zwei Proben Nachsicht /// 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 /// zehn Sekunden waren dieselben zwei Proben zwanzig Sekunden. Ein
/// fünfsekündiges Logo mitten im Abspann zerriss die Strecke deshalb nur /// fünfsekündiges Logo mitten im Abspann zerriss die Strecke deshalb nur
/// bei der dicht abgetasteten Datei — und genau die fiel durch. /// 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; stücke.Count == 0 ? null : stücke[0].Von;
/// <summary>Abspann ab 2340 s, darin fünf Sekunden helles Logo.</summary> /// <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) for (var t = 1500.0; t <= 2700; t += takt)
{ {
var imLogo = t >= 2500 && t < 2505; 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; return proben;
} }
@@ -408,12 +408,12 @@ public class AbspannNachsichtTests
[Test] [Test]
public void Eine_Minute_hell_mittendrin_teilt_den_Abspann_in_zwei_Stuecke() 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) for (var t = 1500.0; t <= 2700; t += 1)
{ {
// Dunkel ab 2340, aber von 2400 bis 2460 eine Minute hell. // Dunkel ab 2340, aber von 2400 bis 2460 eine Minute hell.
var hell = t < 2340 || (t >= 2400 && t < 2460); 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); var stücke = Abspannkante.Suchen(proben, Regeln);
@@ -433,15 +433,15 @@ public class AbspannNachsichtTests
[Test] [Test]
public void Der_Grund_wird_benannt() public void Der_Grund_wird_benannt()
{ {
var hell = new List<Bildprobe>(); var hell = new List<Frameprobe>();
for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Bildprobe(t, 0)); for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Frameprobe(t, 0));
Abspannkante.Suchen(hell, Regeln, out var grund); Abspannkante.Suchen(hell, Regeln, out var grund);
Assert.That(grund, Does.Contain("keine dunkle Strecke")); 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) 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); Abspannkante.Suchen(kurz, Regeln, out var zuKurz);
Assert.That(zuKurz, Does.Contain("zusammen nur")); Assert.That(zuKurz, Does.Contain("zusammen nur"));
@@ -469,13 +469,13 @@ public class AbspannstueckeTests
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Eine Reihe im Sekundentakt, dunkel in den angegebenen Spannen.</summary> /// <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) for (var t = 0.0; t <= bis; t += 1)
{ {
var istDunkel = Array.Exists(dunkel, d => t >= d.Von && t < d.Bis); 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; return proben;
} }
@@ -22,7 +22,7 @@ public class SecretsTests
} }
[Test] [Test]
public void Der_Fingerabdruck_ist_gleichbleibend_und_unterscheidet() public void Der_Fingerfingerprint_ist_gleichbleibend_und_unterscheidet()
{ {
var a = Secrets.Create(); var a = Secrets.Create();
var b = Secrets.Create(); var b = Secrets.Create();
@@ -35,9 +35,9 @@ public class SecretsTests
Assert.That(ersterLauf, Has.Length.EqualTo(64)); 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] [Test]
public void Der_Fingerabdruck_verraet_das_Geheimnis_nicht() public void Der_Fingerfingerprint_verraet_das_Geheimnis_nicht()
{ {
var s = Secrets.Create(); var s = Secrets.Create();
Assert.That(Secrets.Fingerprint(s), Does.Not.Contain(s)); 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 /// verschiedener Bitrate, durch <c>fpcalc</c> gedreht. Zwanzig Kilobyte
/// Text — der Preis dafür, dass diese Zusage nicht bloss behauptet ist. /// Text — der Preis dafür, dass diese Zusage nicht bloss behauptet ist.
/// </remarks> /// </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) 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; if (!File.Exists(pfad)) return null;
return File.ReadAllText(pfad) return File.ReadAllText(pfad)
@@ -38,7 +38,7 @@ public class EchteAbdrueckeTests
var b = Lesen("folgeB.txt"); var b = Lesen("folgeB.txt");
if (a is null || b is null) Assert.Ignore("Keine Abdrücke zum Prüfen da."); 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($"A {a.Count} Werte, B {b.Count} Werte");
TestContext.Out.WriteLine(treffer is null 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, /// rechnen kann. Eine Prüfung, die eine Serie mit echten Folgen braucht,
/// liefe nirgends ausser auf dem Rechner dessen, der sie geschrieben hat. /// liefe nirgends ausser auf dem Rechner dessen, der sie geschrieben hat.
/// </remarks> /// </remarks>
public class TonabgleichTests public class FingerprintabgleichTests
{ {
/// <summary>Chromaprint mit Standardeinstellungen.</summary> /// <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> /// <summary>Eine Folge von Abdrücken, die sich nicht wiederholt.</summary>
/// ///
@@ -40,7 +40,7 @@ public class TonabgleichTests
teile.SelectMany(t => t).ToArray(); teile.SelectMany(t => t).ToArray();
/// <summary>Wie viele Zeitscheiben so viele Sekunden sind.</summary> /// <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] [Test]
public void Ein_gemeinsamer_Vorspann_wird_gefunden() public void Ein_gemeinsamer_Vorspann_wird_gefunden()
@@ -52,7 +52,7 @@ public class TonabgleichTests
// Folge 2: der Vorspann liegt viel später. // Folge 2: der Vorspann liegt viel später.
var b = Aneinander(Rauschen(Scheiben(70), 4), vorspann, Rauschen(Scheiben(20), 5)); 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.That(treffer, Is.Not.Null);
Assert.Multiple(() => Assert.Multiple(() =>
@@ -77,7 +77,7 @@ public class TonabgleichTests
var a = Rauschen(Scheiben(120), 11); var a = Rauschen(Scheiben(120), 11);
var b = Rauschen(Scheiben(120), 12); 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> /// <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 a = Aneinander(Rauschen(Scheiben(30), 22), kurz, Rauschen(Scheiben(30), 23));
var b = Aneinander(Rauschen(Scheiben(50), 24), kurz, Rauschen(Scheiben(10), 25)); 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> /// <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 a = Aneinander(Rauschen(Scheiben(5), 32), vorspann, Rauschen(Scheiben(40), 33));
var b = Aneinander(Rauschen(Scheiben(20), 34), leichtAnders, Rauschen(Scheiben(40), 35)); 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, Is.Not.Null);
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0)); 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 a = Aneinander(Rauschen(Scheiben(5), 42), vorspann, Rauschen(Scheiben(30), 43));
var b = Aneinander(Rauschen(Scheiben(15), 44), mitAnsage, Rauschen(Scheiben(30), 45)); 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, Is.Not.Null);
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0), 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)))); 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"); Assert.That(gefunden, Has.Count.EqualTo(5), "auch die letzte Folge");
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
@@ -160,6 +160,6 @@ public class TonabgleichTests
public void Eine_Folge_allein_ergibt_nichts() public void Eine_Folge_allein_ergibt_nichts()
{ {
var eine = new List<(int, IReadOnlyList<uint>)> { (0, Rauschen(Scheiben(60), 81)) }; 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>
<ItemGroup> <ItemGroup>
<None Include="Abdruecke\**" CopyToOutputDirectory="PreserveNewest" /> <None Include="Fingerprints\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </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> /// <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 /// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig
/// ist — und niemand sähe, warum. /// ist — und niemand sähe, warum.
/// </remarks> /// </remarks>
[Test] [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 laenge = TimeSpan.FromMinutes(45);
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 }; var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
@@ -314,7 +314,7 @@ public sealed class ProgressRuleTests
Assert.That(ergebnis.Watched, Is.True); 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] [Test]
public void Ohne_Abspann_gilt_der_Anteil() 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> /// <summary>Der Satz, der in der Verwaltung mitläuft.</summary>
/// ///
/// <remarks> /// <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 /// hat bisher gar nichts gemeldet — in der Verwaltung stand das Ergebnis
/// des längst beendeten Scans, während der Server noch jede Folge /// 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 /// 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> /// <summary>Mit Zähler, wenn die Gesamtzahl bekannt ist.</summary>
[Test] [Test]
public void Die_Markensuche_nennt_Titel_und_Zaehler() public void Die_Segmentsuche_nennt_Titel_und_Zaehler()
{ {
var schritt = new Scanschritt( var schritt = new Scanschritt(
Scanphase.Marken, "Biohackers S02E01 · Neuanfang", 12, 95); Scanphase.Segmente, "Biohackers S02E01 · Neuanfang", 12, 95);
Assert.That(schritt.Text(), Assert.That(schritt.Text(),
Is.EqualTo("Suche Vorspann und Abspann: Biohackers S02E01 · Neuanfang — 12 von 95")); Is.EqualTo("Suche Vorspann und Abspann: Biohackers S02E01 · Neuanfang — 12 von 95"));
@@ -28,7 +28,7 @@ public class ScanschrittTests
[Test] [Test]
public void Ohne_Gesamtzahl_steht_nur_der_Titel() 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)")); 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> /// <summary>Welche umgepackten Dateien weggeräumt werden.</summary>
/// ///
/// <remarks> /// <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, /// 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 /// die das beendet — ohne Platte, ohne Server, damit die Entscheidung selbst
/// nachlesbar ist und nicht nur ihr Ergebnis. /// nachlesbar ist und nicht nur ihr Ergebnis.
/// </remarks> /// </remarks>
public class KratzplatzregelTests public class TranscoderegelTests
{ {
private static readonly DateTimeOffset Jetzt = new(2026, 10, 8, 12, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Jetzt = new(2026, 10, 8, 12, 0, 0, TimeSpan.Zero);
private const int Bauart = 2; private const int Bauart = 2;
@@ -20,7 +20,7 @@ public class KratzplatzregelTests
private const long Gigabyte = 1024L * 1024 * 1024; 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( private static Paketakte Akte(
Guid id, long gigabyte, TimeSpan vorWieLange, long? geaendertMs = null, int bauart = Bauart) Guid id, long gigabyte, TimeSpan vorWieLange, long? geaendertMs = null, int bauart = Bauart)
{ {
@@ -36,12 +36,12 @@ public class KratzplatzregelTests
private static IReadOnlyList<string> Weg( private static IReadOnlyList<string> Weg(
IReadOnlyList<Paketakte> akten, IReadOnlyList<Paketakte> akten,
Kratzplatzregeln? regeln = null, Transcoderegeln? regeln = null,
IReadOnlyDictionary<Guid, long>? bekannt = null, IReadOnlyDictionary<Guid, long>? bekannt = null,
Func<string, bool>? inBenutzung = null) => Func<string, bool>? inBenutzung = null) =>
Kratzplatzauswahl.Wegzuwerfen( Transcodeauswahl.Wegzuwerfen(
akten, bekannt ?? Bekannt, Bauart, akten, bekannt ?? Bekannt, Bauart,
regeln ?? Kratzplatzregeln.Vorgabe, Jetzt, regeln ?? Transcoderegeln.Vorgabe, Jetzt,
inBenutzung ?? (_ => false)) inBenutzung ?? (_ => false))
.Select(a => a.Pfad).ToList(); .Select(a => a.Pfad).ToList();
@@ -161,7 +161,7 @@ public class KratzplatzregelTests
[Test] [Test]
public void Eine_Obergrenze_von_null_heisst_unbegrenzt() 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[] var akten = new[]
{ {
Akte(EinsId, 500, TimeSpan.FromHours(2)), Akte(EinsId, 500, TimeSpan.FromHours(2)),
@@ -174,7 +174,7 @@ public class KratzplatzregelTests
[Test] [Test]
public void Ohne_Altersabbau_bleibt_auch_sehr_Altes_liegen() 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); Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromDays(400))], regeln), Is.Empty);
} }
@@ -187,7 +187,7 @@ public class KratzplatzregelTests
[Test] [Test]
public void Verwaistes_geht_auch_bei_abgeschalteten_Grenzen() 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)); Assert.That(Weg([Akte(EinsId, 1, TimeSpan.FromHours(2), bauart: 1)], regeln), Has.Count.EqualTo(1));
} }
@@ -195,7 +195,7 @@ public class KratzplatzregelTests
[Test] [Test]
public void Eine_zu_kurze_Schonfrist_wird_hochgesetzt() 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)), Assert.That(regeln.Schonfrist, Is.EqualTo(TimeSpan.FromMinutes(5)),
"eine Minute löschte die Datei mitten im Herunterladen weg"); "eine Minute löschte die Datei mitten im Herunterladen weg");
@@ -204,7 +204,7 @@ public class KratzplatzregelTests
[Test] [Test]
public void Null_und_negative_Werte_heissen_abgeschaltet() public void Null_und_negative_Werte_heissen_abgeschaltet()
{ {
var regeln = Kratzplatzregeln.Geprueft(0, -3, 30); var regeln = Transcoderegeln.Geprueft(0, -3, 30);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -8,41 +8,41 @@ namespace Flaro.Media.Tests;
/// <b>Ohne das Programm selbst.</b> Ob Chromaprint rechnen kann, ist nicht /// <b>Ohne das Programm selbst.</b> Ob Chromaprint rechnen kann, ist nicht
/// unsere Frage; ob wir seine Ausgabe richtig verstehen, schon. Die Zahlen /// unsere Frage; ob wir seine Ausgabe richtig verstehen, schon. Die Zahlen
/// kommen mit Vorzeichen, gemeint sind die Bitmuster — wer das verwechselt, /// 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> /// </remarks>
public class TonabdruckleserTests public class FingerprintleserTests
{ {
[Test] [Test]
public void Die_Zahlen_werden_als_Bitmuster_gelesen() 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] [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] [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> /// <summary>Eine Zeitscheibe ist rund 124 Millisekunden.</summary>
/// ///
/// <remarks> /// <remarks>
/// Daran hängt jede Zeitangabe, die aus einem Abdruck entsteht. Stimmt /// Daran hängt jede Zeitangabe, die aus einem Fingerprint entsteht. Stimmt
/// die Zahl nicht, liegt jede Marke um denselben Faktor daneben — und /// die Zahl nicht, liegt jedes Segment um denselben Faktor daneben — und
/// das sähe aus wie ein Fehler in der Erkennung. /// das sähe aus wie ein Fehler in der Erkennung.
/// </remarks> /// </remarks>
[Test] [Test]
public void Eine_Zeitscheibe_dauert_124_Millisekunden() 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 /// Sekunden: <c>-length 0</c> ergibt 21 786 Werte, also 0,1239 Sekunden je
/// Wert; <c>-length 120</c> bricht nach 948 Werten ab. /// Wert; <c>-length 120</c> bricht nach 948 Werten ab.
/// </remarks> /// </remarks>
public class TonabdruckRateTests public class FingerprintRateTests
{ {
[Test] [Test]
public void Die_Rate_stimmt_mit_der_Messung_ueberein() public void Die_Rate_stimmt_mit_der_Messung_ueberein()
{ {
// 21 786 Werte für 2700 Sekunden. // 21 786 Werte für 2700 Sekunden.
var gemessen = 2700.0 / 21786; 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> /// <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 /// zu gross zu halten und wieder in Ausschnitten zu lesen: eine Stunde
/// sind knapp 29 000 Werte, also 116 Kilobyte. /// sind knapp 29 000 Werte, also 116 Kilobyte.
/// </remarks> /// </remarks>
[Test] [Test]
public void Eine_Stunde_sind_rund_29000_Werte() 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)); Assert.That(werte, Is.InRange(28500, 29500));
} }
} }
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests; namespace Flaro.Media.Tests;
/// <summary>Die Ausgabe des Abtasters lesen — ohne ffmpeg.</summary> /// <summary>Die Ausgabe des Abtasters lesen — ohne ffmpeg.</summary>
public class BildabtasterLesenTests public class FrameabtasterLesenTests
{ {
[Test] [Test]
public void Zeit_und_Wert_werden_zu_Proben() 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[] Assert.That(proben, Is.EqualTo(new[]
{ {
new Bildprobe(62.5, 3), new Frameprobe(62.5, 3),
new Bildprobe(65, 84), new Frameprobe(65, 84),
})); }));
} }
@@ -38,13 +38,13 @@ public class BildabtasterLesenTests
[Test] [Test]
public void Ein_Wert_ohne_Zeit_faellt_heraus() 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] [Test]
public void Leere_Ausgabe_ergibt_nichts() 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. /// rotgemeldet — ein fehlendes Werkzeug ist kein Fehler im Quelltext.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class BildabtasterLaufTests public class FrameabtasterLaufTests
{ {
private string _ordner = ""; private string _ordner = "";
private string _datei = ""; private string _datei = "";
@@ -117,8 +117,8 @@ public class BildabtasterLaufTests
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { } try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
} }
private static Bildabtaster Abtaster() => private static Frameabtaster Abtaster() =>
new(FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance); new(FfmpegOptions.FromEnvironment(), NullLogger<Frameabtaster>.Instance);
/// <summary>Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.</summary> /// <summary>Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.</summary>
[Test] [Test]
@@ -198,7 +198,7 @@ public class BildabtasterLaufTests
/// 450 gewesen. /// 450 gewesen.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class BildabtasterBittiefeTests public class FrameabtasterBittiefeTests
{ {
private string _ordner = ""; private string _ordner = "";
@@ -246,8 +246,8 @@ public class BildabtasterBittiefeTests
[Test] [Test]
public async Task Acht_und_zehn_Bit_ergeben_dieselbe_Zahl() public async Task Acht_und_zehn_Bit_ergeben_dieselbe_Zahl()
{ {
var abtaster = new Bildabtaster( var abtaster = new Frameabtaster(
FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance); FfmpegOptions.FromEnvironment(), NullLogger<Frameabtaster>.Instance);
var acht = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p"), bei: 2); var acht = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p"), bei: 2);
var zehn = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p10le"), 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> /// <summary>Beim Rechnen wird jedes Segment gleich lang.</summary>
[Test] [Test]
public void Beim_Rechnen_stehen_die_Schluesselbilder_fest() public void Beim_Rechnen_stehen_die_Keyframes_fest()
{ {
var zeile = Zeile([Bild("hevc"), Ton(1)]); var zeile = Zeile([Bild("hevc"), Ton(1)]);
@@ -197,15 +197,15 @@ public class HlsLaufTests
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Das ist der Test, der gefehlt hat.</b> Die Quelle hat genau ein /// <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 /// Bildgruppe — der schlimmste Fall. Genau gesprungen schnitte ffmpeg den
/// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge /// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge
/// Bild ohne Ton. /// Bild ohne Ton.
/// </para> /// </para>
/// <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 /// 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. /// 4,98. `-noaccurate_seek` braucht diese Genauigkeit nicht.
/// </para> /// </para>
/// </remarks> /// </remarks>
@@ -229,15 +229,15 @@ public class HlsLaufTests
/// ///
/// <remarks> /// <remarks>
/// <para> /// <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 /// Sekunde 4 ist damit der schlimmste Fall: das kopierte Bild muss ganz
/// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier /// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier
/// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt. /// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt.
/// </para> /// </para>
/// <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. /// 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. /// hier wird geprüft, dass die Zeile das auch verträgt.
/// </para> /// </para>
/// </remarks> /// </remarks>
@@ -247,7 +247,7 @@ public class HlsLaufTests
var quelle = Path.Combine(_ordner, "lang.mkv"); var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle); 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)); var (bild, ton) = Anfang(Laufen(quelle, ab: 0));
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2), Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests; namespace Flaro.Media.Tests;
/// <summary>Sagt die Schlüsselbildsuche dasselbe wie ffmpeg?</summary> /// <summary>Sagt die Keyframesuche dasselbe wie ffmpeg?</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -24,7 +24,7 @@ namespace Flaro.Media.Tests;
/// bleibt. /// bleibt.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class SchluesselbilderTests public class KeyframesTests
{ {
private string _ordner = ""; private string _ordner = "";
private string _datei = ""; private string _datei = "";
@@ -83,8 +83,8 @@ public class SchluesselbilderTests
[TestCase(100.2)] [TestCase(100.2)]
public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch) public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch)
{ {
var suche = new Schluesselbilder( var suche = new Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance); FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.Instance);
var gemeldet = await suche.DavorAsync(_datei, wunsch); var gemeldet = await suche.DavorAsync(_datei, wunsch);
var echt = EchterAnfang(wunsch); var echt = EchterAnfang(wunsch);
@@ -102,8 +102,8 @@ public class SchluesselbilderTests
[TestCase(73.3)] [TestCase(73.3)]
public async Task Nie_hinter_dem_Wunsch(double wunsch) public async Task Nie_hinter_dem_Wunsch(double wunsch)
{ {
var suche = new Schluesselbilder( var suche = new Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance); FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.Instance);
Assert.That(await suche.DavorAsync(_datei, wunsch), Is.LessThanOrEqualTo(wunsch)); 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. */ /** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
heroImage?: string | null; heroImage?: string | null;
/** Der Abspann, sofern er im Bild gefunden wurde. */ /** Der Abspann, sofern er im Bild gefunden wurde. */
markers?: Marke[] | null; markers?: Segment[] | null;
}; };
export type Serie = { export type Serie = {
id: string; title: string; year: number | null; overview: string | null; id: string; title: string; year: number | null; overview: string | null;
@@ -120,7 +120,7 @@ export type Serie = {
heroImage?: string | null; heroImage?: string | null;
}; };
/** Ein überspringbarer Abschnitt einer Folge. */ /** 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 = { export type Folge = {
id: string; season: number; episode: number; 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. */ /** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
seriesId?: string | null; seriesId?: string | null;
/** Vorspann und Abspann, sofern der Server sie gefunden hat. */ /** 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. */ /** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null; publishAt?: string | null;
}; };
@@ -196,11 +196,11 @@ export type Scanstand = {
missing: number | null; missing: number | null;
/** Woran der Lauf gerade ist, in einem Satz. */ /** Woran der Lauf gerade ist, in einem Satz. */
step: string | null; 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 * **Ein eigenes Feld, nicht `step`.** Sie hängt sich hinter den Scan und
* läuft weiter, wenn der längst „fertig" meldet. */ * läuft weiter, wenn der längst „fertig" meldet. */
marken?: string | null; segmente?: string | null;
problems: { path: string; reason: string }[] | null; error: string | null; problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number; metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null; skipped: boolean; error: string | null } | null;
@@ -653,19 +653,19 @@ export const naechsteFolgen = (take = 20) =>
/** Eine Folge für sich — tolerant, wie die Nachbarn. /** Eine Folge für sich — tolerant, wie die Nachbarn.
* *
* Für den Spieler: er bekommt nur eine Kennung und möchte wissen, ob es * 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. */ * nicht daran. */
/** Stösst die Suche nach Vorspann und Abspann einer Serie an. /** Stösst die Suche nach Vorspann und Abspann einer Serie an.
* *
* Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge * Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge
* einer Staffel ein Stück Ton —, und darauf wartet niemand. */ * 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 * **„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
* Dinge.** Ein Titel, bei dem die Suche einmal nichts fand, trägt einen * 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 * Vermerk und keine Marke — und war damit für jeden weiteren Lauf
* erledigt, obwohl er der interessanteste Fall ist. */ * erledigt, obwohl er der interessanteste Fall ist. */
export type Markenumfang = export type Segmentumfang =
/** Nur, wo noch nie gesucht wurde. */ /** Nur, wo noch nie gesucht wurde. */
| 'Neue' | 'Neue'
/** Auch, wo gesucht und nichts gefunden wurde — nicht, wo schon eine Marke steht. */ /** 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, auch was längst eine Marke hat. */
| 'Alles'; | 'Alles';
export const markenSuchen = (serieId: string, umfang: Markenumfang = 'Alles') => export const segmenteSuchen = (serieId: string, umfang: Segmentumfang = 'Alles') =>
anfrage<void>( anfrage<void>(
`/series/${serieId}/marken?umfang=${umfang}`, { method: 'POST' }); `/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 * **Eine Kennung, zwei mögliche Wege.** Der Spieler bekommt nur eine
* Kennung und weiss nicht, was dahintersteckt; erst die Antwort sagt es. * 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. */ * daran. */
export async function markenHolen(titelId: string): Promise<Marke[]> { export async function segmenteHolen(titelId: string): Promise<Segment[]> {
const folge = await folgeLesen(titelId); const folge = await folgeLesen(titelId);
if (folge) return folge.markers ?? []; if (folge) return folge.markers ?? [];
try { try {
const f = await anfrage<{ markers?: Marke[] | null }>(`/movies/${titelId}`); const f = await anfrage<{ markers?: Segment[] | null }>(`/movies/${titelId}`);
return f.markers ?? []; return f.markers ?? [];
} catch { } catch {
return []; 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 * **Antwortet sofort, arbeitet weiter.** Der Lauf liest je Folge ein Stück
* Ton; über fünfundneunzig Serien sind das Stunden. */ * 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' }); anfrage<void>(`/libraries/${id}/marken?umfang=${umfang}`, { method: 'POST' });
/** Die Adresse der Messkurve eines Films — zum Herunterladen. /** 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 * **Keine Erkennung, sondern Zahlen zum Ansehen.** Welche Schwelle bei
* einem Abspann über laufendem Bild trägt, lässt sich nur an der Kurve * einem Abspann über laufendem Bild trägt, lässt sich nur an der Kurve
* entscheiden, nicht am Ergebnis der Erkennung. */ * entscheiden, nicht am Ergebnis der Erkennung. */
export const markenkurveAdresse = (filmId: string) => export const segmentkurveAdresse = (filmId: string) =>
`/api/v1/movies/${filmId}/markenkurve`; `/api/v1/movies/${filmId}/markenkurve`;
/** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */ /** Nimmt die Segmente eines Films wieder weg — wenn die Erkennung danebenlag. */
export const filmmarkenVerwerfen = (filmId: string) => export const filmsegmenteVerwerfen = (filmId: string) =>
anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' }); anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' });
/** Stösst die Abspannsuche für einen Film an. */ /** 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' }); anfrage<void>(`/movies/${filmId}/marken?umfang=${umfang}`, { method: 'POST' });
export async function folgeLesen(id: string): Promise<Folge | null> { 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), 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) => export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' }); anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
@@ -1190,7 +1190,7 @@ export const statistik = (wochen = 12) =>
export type Speicherort = { export type Speicherort = {
name: string; variable: string; pfad: string; name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null; 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; teile: { name: string; frei: number; dateien: number }[] | null;
}; };
@@ -1202,11 +1202,11 @@ export type Aufraeumbericht = {
teile: { name: string; frei: number; dateien: number }[]; 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, * POST und nicht DELETE wie beim Bildspeicher: dort wird ein Ordner geleert,
* hier eine Regel angewandt. */ * hier eine Regel angewandt. */
export const kratzplatzAufraeumen = () => export const transcodeAufraeumen = () =>
anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' }); anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' });
export type Wartungseinstellung = { export type Wartungseinstellung = {
+28 -28
View File
@@ -1,7 +1,7 @@
import type HlsTyp from 'hls.js'; import type HlsTyp from 'hls.js';
import { import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen, stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke, type Stromauskunft, type Spurauskunft, type Folge, type Segment,
} from './api'; } from './api';
import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util'; import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl'; 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 // **Zwei Knöpfe, nicht einer.** Im Abspann einer Serie gibt es zwei
// vernünftige Antworten — weiterschauen oder den Abspann überspringen — // vernünftige Antworten — weiterschauen oder den Abspann überspringen —
// und welche gemeint ist, weiss nur der Zuschauer. // und welche gemeint ist, weiss nur der Zuschauer.
const markenreihe = document.createElement('div'); const segmentreihe = document.createElement('div');
markenreihe.className = 'pl-marken hidden'; 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.appendChild(schicht);
document.body.classList.add('pl-offen'); document.body.classList.add('pl-offen');
@@ -626,7 +626,7 @@ export function spieler(anfrage: Spieleranfrage): void {
let vorige: Folge | null = null; let vorige: Folge | null = null;
let naechste: Folge | null = null; let naechste: Folge | null = null;
/** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */ /** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */
let marken: Marke[] = []; let segmente: Segment[] = [];
// **Eine Folge weiter, eine zurück.** // **Eine Folge weiter, eine zurück.**
// //
@@ -670,11 +670,11 @@ export function spieler(anfrage: Spieleranfrage): void {
const [davor, danach, gefunden] = await Promise.all([ const [davor, danach, gefunden] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'), nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'), nachbarfolge(anfrage.titelId, 'next'),
markenHolen(anfrage.titelId), segmenteHolen(anfrage.titelId),
]); ]);
vorige = davor; vorige = davor;
naechste = danach; naechste = danach;
marken = gefunden; segmente = gefunden;
if (geschlossen) return; if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null); vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.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); kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`; kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
markenstand(jetzt); segmentstand(jetzt);
} }
/** Zeigt den Überspringen-Knopf, solange man in einer Marke steht. /** 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 * mehrmals je Sekunde; die Knöpfe jedesmal neu zu bauen hiesse, einen
* Klick mitten im Drücken zu verlieren. * Klick mitten im Drücken zu verlieren.
*/ */
let markenstandJetzt = ''; let segmentstandJetzt = '';
/** Ob die Marken-Knöpfe inzwischen an der Bedienung hängen. /** Ob die Segmente-Knöpfe inzwischen an der Bedienung hängen.
* *
* **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt ihre * **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 * 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 * Sobald man sie aber einmal eingeblendet hat, gehören die Knöpfe dazu
* und gehen mit ihr. Keine Frist, kein Zeitgeber: der Zuschauer sagt * und gehen mit ihr. Keine Frist, kein Zeitgeber: der Zuschauer sagt
* selbst, wann er genug gesehen hat. */ * selbst, wann er genug gesehen hat. */
let markenGebunden = false; let segmentGebunden = false;
let markeLaeuft = false; let segmentLaeuft = false;
function markenstand(jetzt: number): void { function segmentstand(jetzt: number): void {
const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds); const drin = segmente.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
const abspann = drin?.kind === 'credits'; const abspann = drin?.kind === 'credits';
const weiter = abspann && naechste?.files[0] != null; const weiter = abspann && naechste?.files[0] != null;
const kennung = `${drin?.startSeconds ?? ''}|${weiter}`; const kennung = `${drin?.startSeconds ?? ''}|${weiter}`;
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich, // Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
// bis die Bedienung das nächste Mal auftaucht. // bis die Bedienung das nächste Mal auftaucht.
if (kennung !== markenstandJetzt) markenGebunden = false; if (kennung !== segmentstandJetzt) segmentGebunden = false;
markeLaeuft = drin != null; segmentLaeuft = drin != null;
markenreihe.classList.toggle( segmentreihe.classList.toggle(
'hidden', 'hidden',
!drin || (markenGebunden && schicht.classList.contains('pl-still'))); !drin || (segmentGebunden && schicht.classList.contains('pl-still')));
if (!drin || kennung === markenstandJetzt) return; if (!drin || kennung === segmentstandJetzt) return;
markenstandJetzt = kennung; segmentstandJetzt = kennung;
markenreihe.replaceChildren(); segmentreihe.replaceChildren();
// **Im Abspann einer Serie zuerst die nächste Folge.** Das ist, was man // **Im Abspann einer Serie zuerst die nächste Folge.** Das ist, was man
// dort will; „überspringen" steht daneben für den, der den Abspann // dort will; „überspringen" steht daneben für den, der den Abspann
@@ -878,7 +878,7 @@ export function spieler(anfrage: Spieleranfrage): void {
if (weiter) { if (weiter) {
const w = knopf('Nächste Folge', 'btn btn-primary'); const w = knopf('Nächste Folge', 'btn btn-primary');
w.onclick = e => { e.stopPropagation(); folgeWechseln(naechste); }; w.onclick = e => { e.stopPropagation(); folgeWechseln(naechste); };
markenreihe.appendChild(w); segmentreihe.appendChild(w);
} }
const u = knopf( const u = knopf(
@@ -894,11 +894,11 @@ export function spieler(anfrage: Spieleranfrage): void {
// heisst überspringen Schluss" — und damit bekam niemand die Szene // heisst überspringen Schluss" — und damit bekam niemand die Szene
// je zu sehen. Geschlossen wird nur, wenn nach dieser Marke nichts // je zu sehen. Geschlossen wird nur, wenn nach dieser Marke nichts
// mehr kommt. // 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; } if (abspann && !weiter && !danach) { void beenden(); return; }
void springen(drin.endSeconds); void springen(drin.endSeconds);
}; };
markenreihe.appendChild(u); segmentreihe.appendChild(u);
} }
/** Ans Ende springen und zumachen — für den Abspann ohne Nachfolger. /** 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 // Mausbewegung, auch wenn die Bedienung längst da ist. Das jedes Mal
// als „einmal gezeigt" zu werten hiesse, dem Intro seinen Knopf zu // als „einmal gezeigt" zu werten hiesse, dem Intro seinen Knopf zu
// nehmen, bevor es ihn je hatte. // 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'); 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. // 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(); verbergenPlanen();
} }
function verbergenPlanen(): void { 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, * wiederholen hiesse, vier gleich aussehende Zahlen nebeneinanderzustellen,
* von denen keine sagt, woher sie kommt. Das Kürzel sagt es in zwei Zeichen. * 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. */ * hiesse, fremde Zeichen auszuliefern — mit allem, was daran hängt. */
.bewertung.fremd { font-weight: 500; } .bewertung.fremd { font-weight: 500; }
.bewertungsquelle { .bewertungsquelle {
@@ -685,7 +685,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
/* **Der Primärknopf ist hell, nicht farbig.** /* **Der Primärknopf ist hell, nicht farbig.**
* *
* Genau das macht Netflix: der Abspielknopf ist weiss mit schwarzer Schrift, * 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, * Dingen gleichzeitig — Marke, Navigation, Knopf, Fortschritt, Plakette,
* Stern, Fokus, Filter, Kontokreis. Wenn alles Akzent ist, markiert er * Stern, Fokus, Filter, Kontokreis. Wenn alles Akzent ist, markiert er
* nichts. */ * nichts. */
@@ -928,7 +928,7 @@ textarea.feld {
* Leiste gar nicht. Hier liegt er über dem Bild, rechts über der Stelle, * 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 * an der die Leiste erscheinen würde, und bleibt stehen, solange die Marke
* läuft. */ * läuft. */
.pl-marken { .pl-segmente {
position: absolute; position: absolute;
right: var(--s-section); right: var(--s-section);
bottom: calc(var(--s-section) * 3.5); bottom: calc(var(--s-section) * 3.5);
@@ -936,7 +936,7 @@ textarea.feld {
display: flex; display: flex;
gap: var(--s-snug); 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 { .pl-unten {
display: grid; grid-template-columns: 1fr auto 1fr; 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, film as filmLaden, serie as serieLaden, folgen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen, vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand, type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen, type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, segmenteSuchen,
filmmarkenSuchen, filmmarkenVerwerfen, markenkurveAdresse, filmsegmenteSuchen, filmsegmenteVerwerfen, segmentkurveAdresse,
} from '../api'; } from '../api';
import { import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
@@ -33,7 +33,7 @@ type Art = 'filme' | 'serien';
* die Besetzung mit runden Porträts. */ * die Besetzung mit runden Porträts. */
/** Wie die fremden Quellen heissen — kurz an der Kachel, lang im Tooltip. /** 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 * hiesse, fremde Zeichen auszuliefern; ein Kürzel sagt dasselbe und gehört
* uns. * uns.
* *
@@ -513,7 +513,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'filme') { if (melden && istAdmin() && art === 'filme') {
reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen', reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen',
async () => { async () => {
await filmmarkenSuchen(eintrag.id, 'Alles'); await filmsegmenteSuchen(eintrag.id, 'Alles');
hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür ' hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür '
+ 'die letzten zwanzig Minuten an — das dauert einen Augenblick.'); + 'die letzten zwanzig Minuten an — das dauert einen Augenblick.');
// Kurz warten und nachsehen: anders als bei einer Serie ist das // 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. // begründen. Der Knopf lädt sie als Tabelle herunter.
reihe.appendChild(rundKnopf('protokoll', false, 'Messwerte des Abspanns holen', reihe.appendChild(rundKnopf('protokoll', false, 'Messwerte des Abspanns holen',
async () => { 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 ' hinweisZeigen('Die Messung läuft — der Server sieht sich dafür die '
+ 'letzten zwanzig Minuten dreimal an.'); + '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 // **Nur, wenn es etwas zu verwerfen gibt.** Ein Knopf, der nichts
// wegnehmen kann, ist eine Behauptung über einen Zustand. // wegnehmen kann, ist eine Behauptung über einen Zustand.
if ('markers' in eintrag && (eintrag.markers?.length ?? 0) > 0) { if ('markers' in eintrag && (eintrag.markers?.length ?? 0) > 0) {
reihe.appendChild(rundKnopf('schliessen', false, 'Abspannmarke verwerfen', reihe.appendChild(rundKnopf('schliessen', false, 'Abspannsegment verwerfen',
async () => { async () => {
await filmmarkenVerwerfen(eintrag.id); await filmsegmenteVerwerfen(eintrag.id);
hinweisZeigen('Marke entfernt. Der nächste Scan legt sie nicht wieder an.'); hinweisZeigen('Marke entfernt. Der nächste Scan legt sie nicht wieder an.');
await laden(); await laden();
})); }));
@@ -546,7 +546,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'serien') { if (melden && istAdmin() && art === 'serien') {
reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen', reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen',
async () => { async () => {
await markenSuchen(eintrag.id, 'Alles'); await segmenteSuchen(eintrag.id, 'Alles');
hinweisZeigen('Vorspann und Abspann werden gesucht. ' hinweisZeigen('Vorspann und Abspann werden gesucht. '
+ 'Der Server liest dafür jede Folge einmal durch — das dauert ein paar Minuten.'); + '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 * gar nicht gefunden wurde oder bloss nicht angezeigt wird, ist von
* aussen nicht zu unterscheiden — und beides hat eine andere Ursache. * aussen nicht zu unterscheiden — und beides hat eine andere Ursache.
* Einmal nachsehen zu können erspart eine Runde Raten. */ * 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 (!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 // **Mit Zeiten, nicht bloss mit Namen.** Dass eine Marke da ist, sagt
// noch nicht, dass sie an der richtigen Stelle steht — und genau das // noch nicht, dass sie an der richtigen Stelle steht — und genau das
// ist der Unterschied zwischen „die Erkennung hat nichts gefunden" // ist der Unterschied zwischen „die Erkennung hat nichts gefunden"
// und „der Knopf kommt zur falschen Minute". // und „der Knopf kommt zur falschen Minute".
return marken return segmente
.map(m => `${m.kind === 'intro' ? 'Vorspann' : 'Abspann'} ` .map(m => `${m.kind === 'intro' ? 'Vorspann' : 'Abspann'} `
+ `${dauer(m.startSeconds)}–${dauer(m.endSeconds)}`) + `${dauer(m.startSeconds)}–${dauer(m.endSeconds)}`)
.join(' · '); .join(' · ');
@@ -1135,8 +1135,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
: folge.state && folge.state.positionSeconds > 0 : folge.state && folge.state.positionSeconds > 0
? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min` ? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min`
: ''; : '';
const marken = markenwort(folge.markers); const segmente = segmentwort(folge.markers);
const daneben = [nebenbei, marken].filter(Boolean).join(' · '); const daneben = [nebenbei, segmente].filter(Boolean).join(' · ');
zeile.innerHTML = zeile.innerHTML =
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>` `<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
+ (daneben ? `<span class="winzig">${esc(daneben)}</span>` : ''); + (daneben ? `<span class="winzig">${esc(daneben)}</span>` : '');
+20 -20
View File
@@ -1,6 +1,6 @@
import { import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
mediathekMarken, type Markenumfang, mediathekSegmente, type Segmentumfang,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei, scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek, pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api'; } from '../../api';
@@ -227,10 +227,10 @@ export function bibliotheksbereich(
// Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles, // Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles,
// was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme // was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme
// über das Bild. // über das Bild.
/** Startet einen Markenlauf und hängt die Standzeile daran. */ /** Startet einen Segmentelauf und hängt die Standzeile daran. */
function markenlauf(umfang: Markenumfang): void { function segmentelauf(umfang: Segmentumfang): void {
laufendSetzen(true); 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.'); hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
void verfolgen(false); void verfolgen(false);
}); });
@@ -243,29 +243,29 @@ export function bibliotheksbereich(
// herauskam — und eben nicht die vierhundert, bei denen längst etwas // herauskam — und eben nicht die vierhundert, bei denen längst etwas
// steht. „Alles neu" ist die seltene und teure; sie gehört nicht // steht. „Alles neu" ist die seltene und teure; sie gehört nicht
// hinter denselben Knopf. // hinter denselben Knopf.
const marken = knopf('Fehlende Marken suchen', 'btn'); const segmente = knopf('Fehlende Segmente suchen', 'btn');
marken.title = 'Nimmt sich die Titel vor, bei denen keine Marke steht.'; segmente.title = 'Nimmt sich die Titel vor, bei denen keine Marke steht.';
marken.onclick = () => nachfragen({ segmente.onclick = () => nachfragen({
titel: `Fehlende Marken in „${aktuell.name}" suchen?`, titel: `Fehlende Segmente in „${aktuell.name}" suchen?`,
text: 'Nur, wo noch kein Vorspann oder Abspann steht. Bei einer Serie ' text: 'Nur, wo noch kein Vorspann oder Abspann steht. Bei einer Serie '
+ 'wird eine Staffel ganz gelesen, sobald einer ihrer Folgen etwas ' + 'wird eine Staffel ganz gelesen, sobald einer ihrer Folgen etwas '
+ 'fehlt — der Tonabgleich vergleicht die Folgen miteinander und ' + 'fehlt — der Tonabgleich vergleicht die Folgen miteinander und '
+ 'geht nicht einzeln. Es läuft im Hintergrund weiter.', + 'geht nicht einzeln. Es läuft im Hintergrund weiter.',
bestaetigen: 'Suchen', bestaetigen: 'Suchen',
tun: async () => markenlauf('Luecken'), tun: async () => segmentelauf('Luecken'),
}); });
const markenAlle = knopf('Alles neu durchsuchen', 'btn'); const segmenteAlle = knopf('Alles neu durchsuchen', 'btn');
markenAlle.title = 'Bestimmt auch vorhandene Marken noch einmal.'; segmenteAlle.title = 'Bestimmt auch vorhandene Segmente noch einmal.';
markenAlle.onclick = () => nachfragen({ segmenteAlle.onclick = () => nachfragen({
titel: `Alle Marken in „${aktuell.name}" neu bestimmen?`, titel: `Alle Segmente in „${aktuell.name}" neu bestimmen?`,
text: `Jeder Titel wird noch einmal durchsucht — auch die, bei denen ` 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 ` + `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. ` + `Tonspur und sieht sich bei jedem Film die letzten zwanzig Minuten an. `
+ `Bei ${aktuell.itemCount ?? 'vielen'} Titeln dauert das Stunden. ` + `Bei ${aktuell.itemCount ?? 'vielen'} Titeln dauert das Stunden. `
+ `Für „was fehlt noch?" ist der Knopf daneben der richtige.`, + `Für „was fehlt noch?" ist der Knopf daneben der richtige.`,
bestaetigen: 'Alles neu', bestaetigen: 'Alles neu',
tun: async () => markenlauf('Alles'), tun: async () => segmentelauf('Alles'),
}); });
const umbenennen = knopf('Umbenennen', 'btn'); const umbenennen = knopf('Umbenennen', 'btn');
@@ -335,13 +335,13 @@ export function bibliotheksbereich(
laufendSetzen(false); laufendSetzen(false);
stand.textContent = s.error ?? 'Gescheitert.'; stand.textContent = s.error ?? 'Gescheitert.';
return; return;
} else if (s.marken) { } else if (s.segmente) {
// **Die Markensuche hängt sich hinten an und läuft weiter.** // **Die Segmentesuche hängt sich hinten an und läuft weiter.**
// Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen // Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen
// Zweig stünde hier längst das Ergebnis des Scans, während der // Zweig stünde hier längst das Ergebnis des Scans, während der
// Server noch jede Folge durchhört. // Server noch jede Folge durchhört.
laufendSetzen(true); laufendSetzen(true);
stand.textContent = s.marken; stand.textContent = s.segmente;
} }
// **Alle Zähler auf demselben Takt.** // **Alle Zähler auf demselben Takt.**
@@ -357,9 +357,9 @@ export function bibliotheksbereich(
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten. // Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) geaendert(await laden()); 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. // 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`]; const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch, // „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
// was eine Nummer trägt, aber zu keiner Serie gehört. // 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". // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
const knoepfe = document.createElement('div'); const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe'; knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, marken, markenAlle, pruefung, umbenennen, knoepfe.append(los, auffrischen, segmente, segmenteAlle, pruefung, umbenennen,
zuruecksetzen, entfernen); zuruecksetzen, entfernen);
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun // **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
@@ -1,7 +1,7 @@
import { import {
doppelteZusammenlegen, servernameSetzen, doppelteZusammenlegen, servernameSetzen,
systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte,
bildspeicherLeeren, kratzplatzAufraeumen, type Wiedergabe, bildspeicherLeeren, transcodeAufraeumen, type Wiedergabe,
} from '../../api'; } from '../../api';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog } from '../../dialog'; import { dialog } from '../../dialog';
@@ -246,7 +246,7 @@ export function speicherbereich(): HTMLElement {
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`; + `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
oben.appendChild(text); 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 // das Segmente, umgepackte Filme oder Untertitel sind — und davon
// hängt ab, ob es ein Problem ist. Die Dateizahl steht dabei, weil // 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. // 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.onclick = async () => {
raeumen.disabled = true; raeumen.disabled = true;
try { try {
const bericht = await kratzplatzAufraeumen(); const bericht = await transcodeAufraeumen();
await laden(); await laden();
const gemeldet = document.createElement('p'); const gemeldet = document.createElement('p');
gemeldet.className = 'hinweis'; 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. /** 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 * standen zuerst unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr
* Thema. Sobald es eine Sicherung und ein Aufräumen der Datenbank gibt, * 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 * gehören sie zusammen: alles hier ist etwas, das der Server ohne Zutun
@@ -61,9 +61,9 @@ export function wartungsbereich(): HTMLElement {
liste.innerHTML = ''; liste.innerHTML = '';
stand.forEach(s => liste.appendChild(aufgabe(s, werte))); 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( liste.appendChild(zeile(
'Obergrenze', 'Obergrenze',