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