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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user