diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index b444800..0cf2c87 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -101,6 +101,8 @@ enum Wire { let state: ItemState? /// Ab wann abspielbar. In der Zukunft heisst: noch nicht. let publishAt: Date? + /// Der Abspann, sofern er im Bild gefunden wurde. + let markers: [Marker]? } struct Series: Decodable, Sendable { diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift index 555a3a0..d618aeb 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift @@ -23,7 +23,16 @@ extension Wire.Movie { isFavourite: state?.favourite ?? false, isInProgress: state?.inProgress, availableFrom: publishAt, - versions: [Wire.fassung(id: id, files: files)] + versions: [Wire.fassung(id: id, files: files)], + // **Nur der Abspann.** Einen Vorspann gibt es bei einem Film + // nicht zu finden: erkannt wird er daran, dass er sich über + // die Folgen einer Staffel wiederholt. + markers: (markers ?? []).map { + MediaMarker( + kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown, + startMilliseconds: Int($0.startSeconds * 1000), + endMilliseconds: Int($0.endSeconds * 1000)) + } ) } } diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 3992a1d..9be3c93 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -475,7 +475,16 @@ public sealed record MovieResponse( /// Aufmacherwahl. null heisst „keine ausgesucht"; dann /// gilt wie bisher der Hintergrund. /// - string? HeroImage = null); + string? HeroImage = null, + + /// Der Abspann, sofern er im Bild gefunden wurde. + /// + /// + /// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er + /// daran, dass er sich über die Folgen einer Staffel wiederholt, und + /// ein Film hat nichts, womit er sich vergleichen liesse. + /// + IReadOnlyList? Markers = null); public sealed record CreditResponse( Guid Id, string Name, string Role, string? Character, string? Image); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index d1f48f3..00bbd7b 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -151,12 +151,37 @@ public static class LibraryContentEndpoints return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2); var roh = await db.Marken.AsNoTracking() - .Where(m => folgen.Contains(m.EpisodeId)) - .Select(m => new { m.EpisodeId, m.Art, m.VonSekunden, m.BisSekunden }) + .Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value)) + .Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden }) .ToListAsync(); return roh.ToLookup( - m => m.EpisodeId, + m => m.Id, + m => new MarkerResponse( + m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden)); + } + + /// Dasselbe für Filme. + /// + /// + /// Getrennt und nicht über eine gemeinsame Kennung: eine Marke 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. + /// + private static async Task> FilmmarkenZu( + FlaroContext db, IReadOnlyCollection filme) + { + if (filme.Count == 0) + return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2); + + var roh = await db.Marken.AsNoTracking() + .Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value)) + .Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden }) + .ToListAsync(); + + return roh.ToLookup( + m => m.Id, m => new MarkerResponse( m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden)); } @@ -549,6 +574,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 items = kennungen.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, @@ -561,7 +587,8 @@ public static class LibraryContentEndpoints TitlePinned: m.TitlePinned, SortTitle: m.SortTitleValue, SortTitlePinned: m.SortTitlePinned, - HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow))) + HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow), + Markers: marken[m.Id].ToList())) .ToList(); return Results.Ok(new Page(items, gesamt)); @@ -756,7 +783,8 @@ public static class LibraryContentEndpoints PublishAt: film.PublishAt, ExternalRatings: await FremdwertungenZu(db, film: id, serie: null), LibraryId: film.LibraryId, - HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow))); + HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow), + Markers: (await FilmmarkenZu(db, [id]))[id].ToList())); }).RequireAuthorization(); app.MapGet("/api/v1/series/{id:guid}", async ( @@ -852,6 +880,29 @@ public static class LibraryContentEndpoints // Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel // ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein // 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, bool erneut = false) => + { + if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound(); + + _ = erkennung.FilmAsync(id, erneut); + return Results.Accepted(); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // **Eine Marke 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(); + // Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn + // legte der nächste Scan dieselbe falsche Marke wieder an. + return weg > 0 ? Results.NoContent() : Results.NotFound(); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapPost("/api/v1/series/{id:guid}/marken", async ( Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) => { diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index e2b85e4..77e0470 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -105,6 +105,11 @@ public static class PlaybackEndpoints .Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann) .Select(m => (double?)m.VonSekunden) .FirstOrDefaultAsync() + : zustand.MovieId is { } film + ? await db.Marken.AsNoTracking() + .Where(m => m.MovieId == film && m.Art == Markenart.Abspann) + .Select(m => (double?)m.VonSekunden) + .FirstOrDefaultAsync() : null; var ergebnis = ProgressRule.Apply( diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs index b17a5ee..1b50c93 100644 --- a/server/src/Flaro.Api/Markenerkennung.cs +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -47,6 +47,14 @@ public sealed class Markenerkennung( /// Wie viel vom Ende nach einem Abspann abgesucht wird. public const double Abspannfenster = 10 * 60; + /// Wie weit vom Ende eines Films nach dem Abspann gesucht wird. + /// + /// + /// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem + /// Dreistünder zu viel und bei einem Kurzfilm zu wenig. + /// + public const double Suchbereich = 20 * 60; + /// Was ein gefundenes Stück höchstens lang sein darf. /// /// @@ -130,9 +138,112 @@ public sealed class Markenerkennung( finally { _laufend.TryRemove(serie, out _); } } + await FilmeAsync(db, mediathek, ct); + log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek); } + /// Die Filme einer Mediathek, denen noch ein Abspann fehlt. + /// + /// + /// Getrennt von den Serien, weil der Weg ein anderer ist — und weil + /// eine Mediathek immer nur eines von beidem enthält. Die Schleife + /// läuft dann ins Leere, und das kostet eine Abfrage. + /// + private async Task FilmeAsync(FlaroContext db, Guid mediathek, CancellationToken ct) + { + var offen = await db.Movies.AsNoTracking() + .Where(m => m.LibraryId == mediathek) + .Where(m => !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)) + .Select(m => m.Id) + .ToListAsync(ct); + if (offen.Count == 0) return; + + log.LogInformation("Suche den Abspann in {Anzahl} Filmen.", offen.Count); + + var gefunden = 0; + foreach (var film in offen) + { + if (ct.IsCancellationRequested) return; + try { if (await FilmAsync(film, erneut: false, ct)) gefunden++; } + catch (Exception fehler) + { + log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film); + } + } + + log.LogInformation("Abspann in {Gefunden} von {Anzahl} Filmen gefunden.", + gefunden, offen.Count); + } + + /// Sucht den Abspann eines Films — im Bild. + /// + /// + /// + /// Warum hier ein anderer Weg. Bei einer Folge verrät sich der + /// Abspann dadurch, dass er in jeder Folge derselbe ist. Ein Film hat + /// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau + /// einmal vor. Was bleibt, ist, dass er dunkel ist und bis zum Ende + /// durchhält. + /// + /// + /// Erst grob, dann zielen. Ein Überblick über die letzten zwanzig + /// Minuten sagt, ob es eine dunkle Strecke gibt; er kommt aus + /// Schlüsselbildern und ist deshalb billig, aber auf fünf bis zehn + /// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden. + /// + /// + public async Task FilmAsync(Guid filmId, bool erneut, CancellationToken ct = default) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var abtaster = raum.ServiceProvider.GetRequiredService(); + + var film = await db.Movies.AsNoTracking() + .Where(m => m.Id == filmId) + .Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId, + (m, d) => new { m.Id, d.Path, d.Duration }) + .FirstOrDefaultAsync(ct); + + // Ohne Datei nichts abzutasten, ohne Laufzeit kein Ende zu finden. + if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) return false; + + if (!erneut && await db.Markensuchen.AsNoTracking() + .AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct)) + return false; + + var ab = Math.Max(0, laufzeit.TotalSeconds - Suchbereich); + var proben = await abtaster.UeberblickAsync( + film.Path, ab, laufzeit.TotalSeconds - ab, ct); + + await VermerkenAsync(db, filmId, Markenart.Abspann, ct); + + var regeln = Abspannregeln.Vorgabe; + var grob = Abspannkante.Suchen(proben, regeln); + + if (grob is { } stelle) + { + var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab; + var kante = await Abspannkante.VerfeinernAsync( + hell, stelle, regeln, wo => abtaster.HelligkeitAsync(film.Path, wo, ct)); + + await SchreibenAsync(db, filmId, istFilm: true, Markenart.Abspann, + kante, laufzeit.TotalSeconds, ct); + } + else + { + // Nichts gefunden ist ein Ergebnis. Eine alte Marke, 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); + } + + await db.SaveChangesAsync(ct); + return grob is not null; + } + private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct) { using var raum = scopes.CreateScope(); @@ -223,7 +334,7 @@ public sealed class Markenerkennung( // Ende an; ohne diesen Aufschlag stünde der Knopf bei // Minute zwei. var versatz = fensteranfang.GetValueOrDefault(folgenId); - if (await SchreibenAsync(db, folgenId, art, + if (await SchreibenAsync(db, folgenId, istFilm: false, art, stelle.Von + versatz, stelle.Bis + versatz, ct)) gefunden++; } @@ -253,6 +364,24 @@ public sealed class Markenerkennung( return art == Markenart.Intro ? (0, fenster) : (werte - fenster, fenster); } + /// Hält fest, dass bei diesem Film gesucht wurde. + private async Task VermerkenAsync( + FlaroContext db, Guid filmId, Markenart art, CancellationToken ct) + { + var alt = await db.Markensuchen + .FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct); + + if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; } + + db.Markensuchen.Add(new Markensuche + { + MovieId = filmId, + Art = art, + Folgen = 1, + GelaufenAm = clock.GetUtcNow(), + }); + } + /// Hält fest, dass in dieser Staffel gesucht wurde. private async Task VermerkenAsync( FlaroContext db, Guid serieId, int staffel, Markenart art, int folgen, CancellationToken ct) @@ -278,10 +407,12 @@ public sealed class Markenerkennung( } private async Task SchreibenAsync( - FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct) + FlaroContext db, Guid titelId, bool istFilm, Markenart art, + double von, double bis, CancellationToken ct) { - var alt = await db.Marken - .FirstOrDefaultAsync(m => m.EpisodeId == folgenId && m.Art == art, ct); + var alt = istFilm + ? await db.Marken.FirstOrDefaultAsync(m => m.MovieId == titelId && m.Art == art, ct) + : await db.Marken.FirstOrDefaultAsync(m => m.EpisodeId == titelId && m.Art == art, ct); if (alt is not null) { @@ -293,7 +424,8 @@ public sealed class Markenerkennung( db.Marken.Add(new Marke { - EpisodeId = folgenId, + EpisodeId = istFilm ? null : titelId, + MovieId = istFilm ? titelId : null, Art = art, VonSekunden = von, BisSekunden = bis, diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index fefaecf..87356a1 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -173,6 +173,8 @@ builder.Services.AddSingleton(); // Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den // Vermerk, welche Serie gerade abgetastet wird. builder.Services.AddSingleton(); +// Bei Filmen führt kein Ton zum Abspann — nur das Bild. +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/server/src/Flaro.Core/Library/CatalogEntities.cs b/server/src/Flaro.Core/Library/CatalogEntities.cs index bd255dc..77c07f0 100644 --- a/server/src/Flaro.Core/Library/CatalogEntities.cs +++ b/server/src/Flaro.Core/Library/CatalogEntities.cs @@ -151,7 +151,17 @@ public enum Markenart public sealed class Marke { public Guid Id { get; init; } = Guid.CreateVersion7(); - public required Guid EpisodeId { get; init; } + /// Zu welcher Folge sie gehört — oder zu welchem Film. + /// + /// + /// Genau eines von beiden. Gefunden werden sie auf + /// verschiedenen Wegen: bei einer Folge am Ton, weil der Vorspann sich + /// über die Staffel wiederholt, bei einem Film am Bild, weil ein Film + /// nichts hat, womit er sich vergleichen liesse. Was dabei herauskommt, + /// ist dasselbe — eine Stelle, an der man springen darf. + /// + public Guid? EpisodeId { get; init; } + public Guid? MovieId { get; init; } public required Markenart Art { get; init; } public required double VonSekunden { get; set; } public required double BisSekunden { get; set; } @@ -188,11 +198,22 @@ public sealed class Marke public sealed class Markensuche { public Guid Id { get; init; } = Guid.CreateVersion7(); - public required Guid SeriesId { get; init; } - public required int Staffel { get; init; } + + /// Eine Staffel einer Serie — oder ein Film. + /// + /// + /// Genau eines von beiden, wie bei . Bei einer + /// Staffel steht die Nummer daneben; ein Film hat keine. + /// + public Guid? SeriesId { get; init; } + public int? Staffel { get; init; } + public Guid? MovieId { get; init; } + public required Markenart Art { get; init; } /// Wie viele Folgen die Staffel hatte, als gesucht wurde. + /// + /// Bei einem Film immer eins — es gibt nichts zu zählen. public required int Folgen { get; set; } public DateTimeOffset GelaufenAm { get; set; } diff --git a/server/src/Flaro.Core/Media/Abspannkante.cs b/server/src/Flaro.Core/Media/Abspannkante.cs new file mode 100644 index 0000000..954f3b7 --- /dev/null +++ b/server/src/Flaro.Core/Media/Abspannkante.cs @@ -0,0 +1,161 @@ +namespace Flaro.Core.Media; + +/// Ein Messpunkt aus dem Bild: wann, und wie hell. +/// +/// Die Stelle im Film. +/// Mittlere Leuchtdichte, 0 bis 255. +public readonly record struct Bildprobe(double Sekunde, double Helligkeit); + +/// Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist. +/// +/// +/// Alle an einer Stelle, damit man sie gemeinsam verschieben kann. +/// Jede einzelne ist eine Setzung; verstreut über drei Dateien liesse sich +/// nicht mehr sagen, warum ein Fund herausfiel. +/// +public readonly record struct Abspannregeln +{ + /// Ab wann ein Bild als dunkel gilt (0–255). + /// + /// + /// Gemessen an einem erzeugten Film: Handlung 125, Abspann 18. + /// Fünfunddreissig liegt weit von beiden. + /// + public double Dunkelschwelle { get; init; } + + /// Wie viele helle Proben am Stück eine Strecke nicht brechen. + /// + /// + /// Eine Schrifttafel blitzt hell auf, ein Logo steht zwei Sekunden im + /// Bild. Ohne Nachsicht zerfiele ein Abspann in fünf kurze Strecken, + /// von denen keine lang genug wäre. + /// + public int Lücke { get; init; } + + public double Mindestlänge { get; init; } + public double Höchstlänge { get; init; } + + public static Abspannregeln Vorgabe => new() + { + Dunkelschwelle = 35, + Lücke = 2, + Mindestlänge = 45, + Höchstlänge = 12 * 60, + }; +} + +/// Findet, wo der Abspann eines Films anfängt. +/// +/// +/// +/// Warum das Bild und nicht der Ton. Bei einer Serie erkennt man +/// einen Abspann daran, dass er in jeder Folge derselbe ist. Ein Film hat +/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau +/// einmal vor. Was bleibt, ist, dass er dunkel ist. +/// +/// +/// Gesucht wird nicht „ein dunkles Bild", sondern die Kante. Dunkle +/// Einzelbilder gibt es in jedem Film. Ein Abspann ist eine dunkle Strecke, +/// die bis zum Ende durchhält — und genau das Durchhalten ist das Merkmal, +/// nicht die Dunkelheit. +/// +/// +/// Reine Rechnung, ohne Datei. Das Abtasten braucht ffmpeg; das +/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen, +/// ohne einen Film vorzuhalten — dieselbe Naht wie bei +/// . +/// +/// +public static class Abspannkante +{ + /// Die dunkle Strecke, die bis zum Ende durchhält — oder nichts. + /// + /// + /// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen + /// ungleichmässig liegen: Schlüsselbilder tun das. + /// + public static double? Suchen(IReadOnlyList proben, Abspannregeln regeln) + { + if (proben.Count < 2) return null; + + // **Von hinten.** Der Abspann endet am Dateiende; alles, was davor + // dunkel war und wieder hell wurde, war eine Schwarzblende. + var letzte = proben.Count - 1; + if (proben[letzte].Helligkeit >= regeln.Dunkelschwelle) return null; + + var anfang = letzte; + var hellAmStück = 0; + + for (var i = letzte - 1; i >= 0; i--) + { + if (proben[i].Helligkeit < regeln.Dunkelschwelle) + { + anfang = i; + hellAmStück = 0; + continue; + } + + if (++hellAmStück > regeln.Lücke) break; + } + + var länge = proben[letzte].Sekunde - proben[anfang].Sekunde; + if (länge < regeln.Mindestlänge || länge > regeln.Höchstlänge) return null; + + // **Die erste dunkle Probe ist nicht der Anfang.** Zwischen ihr und + // der letzten hellen davor liegt die Kante irgendwo; wo genau, sagt + // erst das Nachmessen. Hier steht die späteste Stelle, an der sie + // liegen kann — und die ist als Marke schon brauchbar. + return proben[anfang].Sekunde; + } + + /// Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen. + public static double? HellDavor( + IReadOnlyList proben, double dunkelAb, Abspannregeln regeln) + { + double? letzteHelle = null; + foreach (var p in proben) + { + if (p.Sekunde >= dunkelAb) break; + if (p.Helligkeit >= regeln.Dunkelschwelle) letzteHelle = p.Sekunde; + } + return letzteHelle; + } + + /// Sucht die Kante zwischen hell und dunkel genauer ein. + /// + /// + /// + /// Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf. Die + /// grobe Reihe stammt aus Schlüsselbildern, 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 + /// Intro Skipper, und hört bei denselben vier Sekunden auf. + /// + /// + /// liefert die Helligkeit an einer Stelle oder + /// null, wenn dort nichts zu holen war. Eine Sonde ohne Antwort + /// beendet die Suche — dann gilt, was die grobe Reihe sagte. + /// + /// + public static async Task VerfeinernAsync( + double hell, double dunkel, Abspannregeln regeln, + Func> messen, double genauigkeit = 4) + { + if (genauigkeit <= 0) genauigkeit = 1; + + while (dunkel - hell > genauigkeit) + { + var mitte = hell + (dunkel - hell) / 2; + var wert = await messen(mitte); + if (wert is not { } helligkeit) return dunkel; + + if (helligkeit < regeln.Dunkelschwelle) dunkel = mitte; + else hell = mitte; + } + + // Die dunkle Seite gewinnt: lieber ein paar Sekunden Abspann zu viel + // überspringen als eine Sekunde Handlung. + return dunkel; + } +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 2e5ef9c..4422b6a 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -311,10 +311,12 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon b.Entity(e => { e.HasKey(x => x.Id); - // Eine Art einmal je Folge: ein zweiter Lauf schreibt um, statt + // Eine Art einmal je Titel: ein zweiter Lauf schreibt um, statt // eine zweite Zeile anzulegen. - e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique(); + e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique().HasFilter("\"EpisodeId\" IS NOT NULL"); + e.HasIndex(x => new { x.MovieId, x.Art }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL"); e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); // Als Zahl, nicht als Text: die Namen stehen im Quelltext und // sollen sich umbenennen lassen, ohne die Datenbank anzufassen. e.Property(x => x.Art).HasConversion(); @@ -323,8 +325,12 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon b.Entity(e => { e.HasKey(x => x.Id); - e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique(); + e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + e.HasIndex(x => new { x.MovieId, x.Art }).IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); e.Property(x => x.Art).HasConversion(); }); diff --git a/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.Designer.cs b/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.Designer.cs new file mode 100644 index 0000000..a4b7577 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.Designer.cs @@ -0,0 +1,1361 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261013041944_Filmmarken")] + partial class Filmmarken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anzeige") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Quelle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Wert") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("MovieId", "Quelle") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Quelle") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Fremdbewertungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Marke", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .HasColumnType("INTEGER"); + + b.Property("BisSekunden") + .HasColumnType("REAL"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErkanntAm") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("VonSekunden") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Art") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Art") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Marken"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Markensuche", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .HasColumnType("INTEGER"); + + b.Property("Folgen") + .HasColumnType("INTEGER"); + + b.Property("GelaufenAm") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("Aufmacherbilder") + .HasColumnType("TEXT"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("Aufmacherbilder") + .HasColumnType("TEXT"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwort", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stichworte"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StichwortId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("StichwortId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("StichwortId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Stichwortlinks"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", 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.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.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) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", 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); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", 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); + + b.HasOne("Flaro.Core.Library.Stichwort", null) + .WithMany() + .HasForeignKey("StichwortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", 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); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", 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); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.cs b/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.cs new file mode 100644 index 0000000..854e12a --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261013041944_Filmmarken.cs @@ -0,0 +1,181 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Filmmarken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Markensuchen_SeriesId_Staffel_Art", + table: "Markensuchen"); + + migrationBuilder.DropIndex( + name: "IX_Marken_EpisodeId_Art", + table: "Marken"); + + migrationBuilder.AlterColumn( + name: "Staffel", + table: "Markensuchen", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "SeriesId", + table: "Markensuchen", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "MovieId", + table: "Markensuchen", + type: "TEXT", + nullable: true); + + migrationBuilder.AlterColumn( + name: "EpisodeId", + table: "Marken", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "MovieId", + table: "Marken", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Markensuchen_MovieId_Art", + table: "Markensuchen", + columns: new[] { "MovieId", "Art" }, + unique: true, + filter: "\"MovieId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Markensuchen_SeriesId_Staffel_Art", + table: "Markensuchen", + columns: new[] { "SeriesId", "Staffel", "Art" }, + unique: true, + filter: "\"SeriesId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Marken_EpisodeId_Art", + table: "Marken", + columns: new[] { "EpisodeId", "Art" }, + unique: true, + filter: "\"EpisodeId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Marken_MovieId_Art", + table: "Marken", + columns: new[] { "MovieId", "Art" }, + unique: true, + filter: "\"MovieId\" IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_Marken_Movies_MovieId", + table: "Marken", + column: "MovieId", + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Markensuchen_Movies_MovieId", + table: "Markensuchen", + column: "MovieId", + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Marken_Movies_MovieId", + table: "Marken"); + + migrationBuilder.DropForeignKey( + name: "FK_Markensuchen_Movies_MovieId", + table: "Markensuchen"); + + migrationBuilder.DropIndex( + name: "IX_Markensuchen_MovieId_Art", + table: "Markensuchen"); + + migrationBuilder.DropIndex( + name: "IX_Markensuchen_SeriesId_Staffel_Art", + table: "Markensuchen"); + + migrationBuilder.DropIndex( + name: "IX_Marken_EpisodeId_Art", + table: "Marken"); + + migrationBuilder.DropIndex( + name: "IX_Marken_MovieId_Art", + table: "Marken"); + + migrationBuilder.DropColumn( + name: "MovieId", + table: "Markensuchen"); + + migrationBuilder.DropColumn( + name: "MovieId", + table: "Marken"); + + migrationBuilder.AlterColumn( + name: "Staffel", + table: "Markensuchen", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SeriesId", + table: "Markensuchen", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EpisodeId", + table: "Marken", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Markensuchen_SeriesId_Staffel_Art", + table: "Markensuchen", + columns: new[] { "SeriesId", "Staffel", "Art" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Marken_EpisodeId_Art", + table: "Marken", + columns: new[] { "EpisodeId", "Art" }, + unique: true); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 4d49f06..8c4cf0e 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -418,19 +418,27 @@ namespace Flaro.Data.Migrations b.Property("BisSekunden") .HasColumnType("REAL"); - b.Property("EpisodeId") + b.Property("EpisodeId") .HasColumnType("TEXT"); b.Property("ErkanntAm") .HasColumnType("INTEGER"); + b.Property("MovieId") + .HasColumnType("TEXT"); + b.Property("VonSekunden") .HasColumnType("REAL"); b.HasKey("Id"); b.HasIndex("EpisodeId", "Art") - .IsUnique(); + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Art") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); b.ToTable("Marken"); }); @@ -450,16 +458,24 @@ namespace Flaro.Data.Migrations b.Property("GelaufenAm") .HasColumnType("INTEGER"); - b.Property("SeriesId") + b.Property("MovieId") .HasColumnType("TEXT"); - b.Property("Staffel") + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Staffel") .HasColumnType("INTEGER"); b.HasKey("Id"); + b.HasIndex("MovieId", "Art") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + b.HasIndex("SeriesId", "Staffel", "Art") - .IsUnique(); + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); b.ToTable("Markensuchen"); }); @@ -1194,17 +1210,25 @@ namespace Flaro.Data.Migrations b.HasOne("Flaro.Core.Library.Episode", null) .WithMany() .HasForeignKey("EpisodeId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .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) - .IsRequired(); + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => diff --git a/server/src/Flaro.Media/Bildabtaster.cs b/server/src/Flaro.Media/Bildabtaster.cs new file mode 100644 index 0000000..dd2e9ec --- /dev/null +++ b/server/src/Flaro.Media/Bildabtaster.cs @@ -0,0 +1,192 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; +using Flaro.Core.Media; +using Microsoft.Extensions.Logging; + +namespace Flaro.Media; + +/// Misst, wie hell ein Film an einer Stelle ist. +/// +/// +/// +/// Der einzige Teil der Abspannsuche, der ffmpeg anfasst. Was +/// daraus folgt, rechnet ohne Datei — dieselbe +/// Naht wie zwischen Tonabdruckleser und Tonabgleich. +/// +/// +/// Zwei Griffe, weil zwei Fragen. „Gibt es am Ende überhaupt eine +/// dunkle Strecke" verlangt einen groben Überblick über zwanzig Minuten; +/// „wo genau fängt sie an" verlangt eine einzelne Stelle. Mit demselben +/// Werkzeug beantwortet wären beide zu teuer: der Überblick fein +/// abgetastet heisst zwanzig Minuten dekodieren, die Stelle grob gesucht +/// heisst zehn Sekunden Ungenauigkeit. +/// +/// +public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger log) +{ + /// Auf welche Breite verkleinert wird, bevor gerechnet wird. + /// + /// + /// Für einen Mittelwert über das ganze Bild braucht es keine 1920 + /// Spalten. Gemessen: bei 160 Spalten kostet der Filtergraph nichts + /// Nennenswertes mehr, und der Mittelwert ist derselbe. + /// + private const string Breite = "160"; + + /// Die Helligkeit an den Schlüsselbildern eines Abschnitts. + /// + /// + /// Nur Schlüsselbilder, und das ist der ganze Trick. 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 + /// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke + /// zu finden, zu wenig für ihre Kante. Die holt + /// nach. + /// + public async Task> UeberblickAsync( + string pfad, double ab, double dauer, CancellationToken ct = default) + { + var ausgabe = await LaufenAsync( + pfad, ab, dauer, nurSchluesselbilder: true, TimeSpan.FromMinutes(5), ct); + + return Lesen(ausgabe, ab); + } + + /// Die mittlere Helligkeit einer einzelnen Sekunde. + /// + /// + /// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst + /// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Schlüsselbild + /// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind + /// das zehn Sekunden Bildmaterial, also Millisekunden. + /// + public async Task HelligkeitAsync( + string pfad, double bei, CancellationToken ct = default) + { + var ausgabe = await LaufenAsync( + pfad, Math.Max(0, bei), dauer: 1, nurSchluesselbilder: false, + TimeSpan.FromSeconds(60), ct); + + var proben = Lesen(ausgabe, 0); + if (proben.Count == 0) return null; + return proben.Average(p => p.Helligkeit); + } + + private async Task LaufenAsync( + string pfad, double ab, double dauer, bool nurSchluesselbilder, + TimeSpan frist, CancellationToken ct) + { + var start = new ProcessStartInfo(optionen.Ffmpeg) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + // **Kein Lesen von der Tastatur.** Ohne das greift ffmpeg nach der + // Standardeingabe des Servers und kann einen Dienst anhalten, der + // ohne Terminal läuft. + start.ArgumentList.Add("-nostdin"); + start.ArgumentList.Add("-v"); + start.ArgumentList.Add("error"); + + if (nurSchluesselbilder) + { + start.ArgumentList.Add("-skip_frame"); + start.ArgumentList.Add("nokey"); + } + + // **Suchen vor dem Öffnen.** Danach müsste ffmpeg die Datei von + // vorn dekodieren — bei einem Zweistünder zwei Stunden Bild für + // zwanzig Minuten Antwort. + start.ArgumentList.Add("-ss"); + start.ArgumentList.Add(ab.ToString("0.###", CultureInfo.InvariantCulture)); + start.ArgumentList.Add("-i"); + start.ArgumentList.Add(pfad); + start.ArgumentList.Add("-t"); + start.ArgumentList.Add(dauer.ToString("0.###", CultureInfo.InvariantCulture)); + start.ArgumentList.Add("-an"); + start.ArgumentList.Add("-sn"); + start.ArgumentList.Add("-vf"); + start.ArgumentList.Add( + $"scale={Breite}:-2,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-"); + start.ArgumentList.Add("-f"); + start.ArgumentList.Add("null"); + start.ArgumentList.Add("-"); + + using var zeit = CancellationTokenSource.CreateLinkedTokenSource(ct); + zeit.CancelAfter(frist); + + try + { + using var prozess = Process.Start(start); + if (prozess is null) return ""; + + var lesen = prozess.StandardOutput.ReadToEndAsync(zeit.Token); + var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeit.Token); + await prozess.WaitForExitAsync(zeit.Token); + var ausgabe = await lesen; + var fehlertext = await fehlerLesen; + + if (prozess.ExitCode != 0) + { + log.LogWarning("ffmpeg gab {Code} beim Abtasten von {Pfad} zurück: {Fehler}", + prozess.ExitCode, pfad, fehlertext.Trim()); + return ""; + } + + return ausgabe; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception fehler) + { + log.LogWarning(fehler, "Abtasten von {Pfad} misslungen.", pfad); + return ""; + } + } + + /// Liest die Ausgabe des metadata-Filters. + /// + /// + /// Sie besteht aus Paaren: eine Zeile mit pts_time, darunter eine + /// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem + /// Dateianfang — deshalb der Aufschlag. + /// + public static IReadOnlyList Lesen(string ausgabe, double versatz) + { + var proben = new List(); + double? zeit = null; + + foreach (var zeile in ausgabe.Split('\n')) + { + if (Zeitzeile().Match(zeile) is { Success: true } z + && double.TryParse(z.Groups[1].Value, NumberStyles.Float, + CultureInfo.InvariantCulture, out var t)) + { + zeit = t; + continue; + } + + if (Wertzeile().Match(zeile) is { Success: true } w + && zeit is { } sekunde + && double.TryParse(w.Groups[1].Value, NumberStyles.Float, + CultureInfo.InvariantCulture, out var wert)) + { + proben.Add(new Bildprobe(versatz + sekunde, wert)); + zeit = null; + } + } + + return proben; + } + + [GeneratedRegex(@"pts_time:([\d.]+)")] + private static partial Regex Zeitzeile(); + + [GeneratedRegex(@"lavfi\.signalstats\.YAVG=([\d.]+)")] + private static partial Regex Wertzeile(); +} diff --git a/server/src/Flaro.Media/Schluesselbilder.cs b/server/src/Flaro.Media/Schluesselbilder.cs index 21dc5f2..0193e3d 100644 --- a/server/src/Flaro.Media/Schluesselbilder.cs +++ b/server/src/Flaro.Media/Schluesselbilder.cs @@ -42,13 +42,43 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger private static readonly TimeSpan Rueckschau = TimeSpan.FromSeconds(30); + /// Wie weit über den Wunsch hinaus gelesen wird. + /// + /// + /// + /// Ohne diesen Nachlauf fehlte das letzte Schlüsselbild. Hier + /// stand eine halbe Sekunde, und ffprobe hörte damit regelmässig eine + /// ganze Bildgruppe zu früh auf: -read_intervals bricht am Ende + /// des Fensters auf Paketebene ab, und das letzte Bild darin kam gar + /// nicht mehr heraus. + /// + /// + /// Nachgemessen an einer Datei mit Schlüsselbildern alle vier Sekunden, + /// gegen die Stelle, an der ffmpeg tatsächlich anfängt: + /// + /// + /// Wunsch Suche (0,5 s) ffmpeg Abweichung + /// 52,0 48 52,0 −4,0 + /// 73,3 68 72,0 −4,0 + /// 100,2 96 100,0 −4,0 + /// + /// + /// Zu früh gemeldet heisst: der Spieler glaubt, früher zu stehen, als er + /// steht — und die Untertitel kommen vier Sekunden zu spät. Mit zehn + /// Sekunden Nachlauf lag dieselbe Messung bei allen Werten auf 0,000. + /// Was über den Wunsch hinausgelesen wird, verwirft die Filterung + /// ohnehin; es kostet ein paar Einträge mehr und sonst nichts. + /// + /// + private const double Nachlauf = 10; + /// Das Schlüsselbild bei oder vor . public async Task DavorAsync(string pfad, double ab, CancellationToken ct = default) { if (ab <= 0) return 0; var von = Math.Max(0, ab - Rueckschau.TotalSeconds); - var spanne = $"{Zahl(von)}%+{Zahl(ab - von + 0.5)}"; + var spanne = $"{Zahl(von)}%+{Zahl(ab - von + Nachlauf)}"; var start = new ProcessStartInfo(optionen.Ffprobe) { diff --git a/server/tests/Flaro.Api.Tests/MarkenTests.cs b/server/tests/Flaro.Api.Tests/MarkenTests.cs index 56d3cdd..18a488d 100644 --- a/server/tests/Flaro.Api.Tests/MarkenTests.cs +++ b/server/tests/Flaro.Api.Tests/MarkenTests.cs @@ -216,4 +216,84 @@ public class MarkenTests "die Folgenzahl löst den nächsten Lauf aus"); } } + + /// Ein Film trägt seine Abspannmarke genauso. + [Test] + public async Task Ein_Film_bekommt_seine_Marke() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Movies"); + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var film = filme!.Items[0].Id; + + await server.InDerDatenbank(async db => + { + db.Marken.Add(new Marke + { + MovieId = film, Art = Markenart.Abspann, + VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var einzeln = await client.GetFromJsonAsync($"/api/v1/movies/{film}"); + var liste = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(einzeln!.Markers!.Single().Kind, Is.EqualTo("credits")); + Assert.That(einzeln.Markers!.Single().StartSeconds, Is.EqualTo(6900)); + Assert.That(liste!.Items[0].Markers, Has.Count.EqualTo(1), "auch in der Liste"); + }); + } + + /// Eine falsche Marke lässt sich 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. + /// + [Test] + public async Task Eine_falsche_Filmmarke_laesst_sich_verwerfen() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Movies"); + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var film = filme!.Items[0].Id; + + await server.InDerDatenbank(async db => + { + db.Marken.Add(new Marke + { + MovieId = film, Art = Markenart.Abspann, + VonSekunden = 60, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow, + }); + db.Markensuchen.Add(new Markensuche + { + MovieId = film, Art = Markenart.Abspann, Folgen = 1, + GelaufenAm = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var weg = await client.DeleteAsync($"/api/v1/movies/{film}/marken"); + var danach = await client.GetFromJsonAsync($"/api/v1/movies/{film}"); + + var vermerke = 0; + await server.InDerDatenbank(async db => + vermerke = await db.Markensuchen.CountAsync(x => x.MovieId == film)); + + Assert.Multiple(() => + { + Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(danach!.Markers, Is.Empty); + Assert.That(vermerke, Is.EqualTo(1), + "der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an"); + }); + } } diff --git a/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs b/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs new file mode 100644 index 0000000..0bbc4e8 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs @@ -0,0 +1,159 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Wo der Abspann eines Films anfängt. +/// +/// +/// Mit erfundenen Messreihen statt mit Filmen. Geprüft wird das +/// Beurteilen, nicht ob ffmpeg rechnen kann. Die Zahlen stammen aus einer +/// Messung an einem erzeugten Film: Handlung 125, Abspann 18. +/// +public class AbspannkanteTests +{ + private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; + + /// Eine Reihe von Messpunkten im Abstand von zehn Sekunden. + /// + /// Ab wann dunkel. null heisst: nie. + private static List Reihe(double von, double bis, double? abspannAb) + { + var proben = new List(); + for (var t = von; t <= bis; t += 10) + proben.Add(new Bildprobe(t, abspannAb is { } ab && t >= ab ? 18 : 125)); + return proben; + } + + [Test] + public void Eine_dunkle_Strecke_bis_zum_Ende_ist_der_Abspann() + { + var proben = Reihe(0, 600, abspannAb: 420); + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420)); + } + + /// Ein Film, der hell endet, hat keinen erkennbaren Abspann. + /// + /// + /// Namen über laufendem Bild — Marvel, Pixar, jede zweite Komödie. Kein + /// Fund ist hier die richtige Antwort: ein geratener Knopf schnitte + /// Handlung weg. + /// + [Test] + public void Ohne_dunkles_Ende_kein_Fund() + { + Assert.That(Abspannkante.Suchen(Reihe(0, 600, abspannAb: null), Regeln), Is.Null); + } + + /// Eine Schwarzblende mitten im Film zählt nicht. + [Test] + public void Eine_Schwarzblende_in_der_Mitte_zaehlt_nicht() + { + var proben = Reihe(0, 600, abspannAb: null); + for (var i = 20; i < 30; i++) proben[i] = proben[i] with { Helligkeit = 10 }; + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null, + "sie hält nicht bis zum Ende durch"); + } + + /// Eine Szene nach dem Abspann nimmt den Fund weg. + /// + /// + /// Lieber keine Marke als eine, die über die Szene hinwegspringt. + /// + [Test] + public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg() + { + var proben = Reihe(0, 600, abspannAb: 420); + for (var i = proben.Count - 5; i < proben.Count; i++) + proben[i] = proben[i] with { Helligkeit = 120 }; + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null); + } + + /// Eine helle Schrifttafel mittendrin bricht die Strecke nicht. + [Test] + public void Eine_kurze_helle_Tafel_bricht_nichts() + { + var proben = Reihe(0, 600, abspannAb: 420); + // Zwei Proben, also zwanzig Sekunden, sind hell. + proben[50] = proben[50] with { Helligkeit = 140 }; + proben[51] = proben[51] with { Helligkeit = 140 }; + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420)); + } + + /// Zwanzig Sekunden Dunkelheit sind kein Abspann. + [Test] + public void Zu_kurz_zaehlt_nicht() + { + Assert.That(Abspannkante.Suchen(Reihe(0, 600, abspannAb: 580), Regeln), Is.Null); + } + + /// Ein halber Film im Dunkeln ist ein Fehlfund. + [Test] + public void Zu_lang_zaehlt_auch_nicht() + { + Assert.That(Abspannkante.Suchen(Reihe(0, 3600, abspannAb: 600), Regeln), Is.Null); + } + + [Test] + public void Die_letzte_helle_Probe_begrenzt_die_Suche() + { + var proben = Reihe(0, 600, abspannAb: 420); + + Assert.That(Abspannkante.HellDavor(proben, 420, Regeln), Is.EqualTo(410)); + } + + // ---------- Nachmessen ---------- + + /// Die binäre Suche findet die Kante auf vier Sekunden genau. + [Test] + public async Task Die_Kante_wird_eingegrenzt() + { + const double echteKante = 417.3; + var sonden = 0; + + var kante = await Abspannkante.VerfeinernAsync(410, 420, Regeln, stelle => + { + sonden++; + return Task.FromResult(stelle >= echteKante ? 18 : 125); + }); + + Assert.Multiple(() => + { + Assert.That(kante, Is.EqualTo(echteKante).Within(4)); + Assert.That(kante, Is.GreaterThanOrEqualTo(echteKante), + "lieber ein paar Sekunden Abspann zu viel als eine Sekunde Handlung"); + Assert.That(sonden, Is.LessThanOrEqualTo(4), "zehn Sekunden auf vier: zwei Halbierungen"); + }); + } + + /// Antwortet eine Sonde nicht, gilt der grobe Fund. + [Test] + public async Task Ohne_Antwort_bleibt_es_beim_groben_Fund() + { + var kante = await Abspannkante.VerfeinernAsync( + 410, 420, Regeln, _ => Task.FromResult(null)); + + Assert.That(kante, Is.EqualTo(420)); + } + + /// Ein schon enger Bereich braucht keine Sonde. + [Test] + public async Task Ein_enger_Bereich_wird_nicht_angefasst() + { + var sonden = 0; + var kante = await Abspannkante.VerfeinernAsync(418, 420, Regeln, _ => + { + sonden++; + return Task.FromResult(18); + }); + + Assert.Multiple(() => + { + Assert.That(kante, Is.EqualTo(420)); + Assert.That(sonden, Is.Zero); + }); + } +} diff --git a/server/tests/Flaro.Media.Tests/BildabtasterTests.cs b/server/tests/Flaro.Media.Tests/BildabtasterTests.cs new file mode 100644 index 0000000..6605000 --- /dev/null +++ b/server/tests/Flaro.Media.Tests/BildabtasterTests.cs @@ -0,0 +1,159 @@ +using System.Diagnostics; +using Flaro.Core.Media; +using Flaro.Media; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Media.Tests; + +/// Die Ausgabe des Abtasters lesen — ohne ffmpeg. +public class BildabtasterLesenTests +{ + [Test] + public void Zeit_und_Wert_werden_zu_Proben() + { + var ausgabe = """ + frame:0 pts:30720 pts_time:2.5 + lavfi.signalstats.YAVG=125.065 + frame:1 pts:61440 pts_time:5 + lavfi.signalstats.YAVG=18.4 + + """; + + var proben = Bildabtaster.Lesen(ausgabe, versatz: 60); + + Assert.That(proben, Is.EqualTo(new[] + { + new Bildprobe(62.5, 125.065), + new Bildprobe(65, 18.4), + })); + } + + /// Ohne Zeitstempel kein Messpunkt. + /// + /// + /// Eine Wertzeile ohne vorangehende Zeitzeile gehört zu keinem + /// Zeitpunkt. Sie trotzdem zu nehmen hiesse, sie der vorigen Zeit + /// zuzuschlagen — und damit eine Messung zu erfinden. + /// + [Test] + public void Ein_Wert_ohne_Zeit_faellt_heraus() + { + Assert.That(Bildabtaster.Lesen("lavfi.signalstats.YAVG=18.4\n", 0), Is.Empty); + } + + [Test] + public void Leere_Ausgabe_ergibt_nichts() + { + Assert.That(Bildabtaster.Lesen("", 0), Is.Empty); + } +} + +/// Den ganzen Weg an einer erzeugten Datei. +/// +/// +/// +/// Die einzige Prüfung hier, die ffmpeg braucht — und sie ist es +/// wert: zwischen „der Filtergraph ist richtig geschrieben" und „die +/// Zahlen kommen an der richtigen Stelle heraus" liegt alles, was man +/// sonst erst am Bestand merkt. +/// +/// +/// Die Datei entsteht im Test: 90 Sekunden Bild, danach 60 Sekunden weisse +/// Schrift auf schwarzem Grund. Fehlt ffmpeg, wird übersprungen statt +/// rotgemeldet — ein fehlendes Werkzeug ist kein Fehler im Quelltext. +/// +/// +public class BildabtasterLaufTests +{ + private string _ordner = ""; + private string _datei = ""; + + [OneTimeSetUp] + public void Erzeugen() + { + _ordner = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), "flaro-abspann-" + Guid.CreateVersion7().ToString("N"))).FullName; + _datei = Path.Combine(_ordner, "pruef.mp4"); + + var start = new ProcessStartInfo("ffmpeg") + { + ArgumentList = + { + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=24:duration=90", + "-f", "lavfi", "-i", "color=c=black:s=640x360:r=24:d=60", + "-f", "lavfi", "-i", "color=c=white:s=180x14:r=24:d=60", + "-filter_complex", + "[1][2]overlay=x=230:y='H-mod(t*45,H+h)'[cr];[0][cr]concat=n=2:v=1:a=0[v]", + "-map", "[v]", "-c:v", "libx264", "-preset", "ultrafast", + "-g", "48", "-pix_fmt", "yuv420p", _datei, + }, + RedirectStandardError = true, + UseShellExecute = false, + }; + + try + { + using var prozess = Process.Start(start); + prozess!.WaitForExit(60_000); + if (prozess.ExitCode != 0 || !File.Exists(_datei)) + Assert.Ignore("ffmpeg konnte die Prüfdatei nicht erzeugen."); + } + catch (Exception) + { + Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen."); + } + } + + [OneTimeTearDown] + public void Aufraeumen() + { + try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { } + } + + private static Bildabtaster Abtaster() => + new(FfmpegOptions.FromEnvironment(), NullLogger.Instance); + + /// Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden. + [Test] + public async Task Der_Abspann_wird_gefunden() + { + var proben = await Abtaster().UeberblickAsync(_datei, ab: 30, dauer: 120); + Assert.That(proben, Is.Not.Empty, "der Filtergraph muss Zahlen liefern"); + + var grob = Abspannkante.Suchen(proben, Abspannregeln.Vorgabe); + Assert.That(grob, Is.Not.Null, "eine dunkle Strecke bis zum Ende muss auffallen"); + + var hell = Abspannkante.HellDavor(proben, grob!.Value, Abspannregeln.Vorgabe) ?? 0; + var abtaster = Abtaster(); + var kante = await Abspannkante.VerfeinernAsync( + hell, grob.Value, Abspannregeln.Vorgabe, + stelle => abtaster.HelligkeitAsync(_datei, stelle)); + + Assert.That(kante, Is.EqualTo(90).Within(4), + "erzeugt wurde der Übergang bei genau 90 Sekunden"); + } + + /// Im hellen Teil gibt es nichts zu finden. + [Test] + public async Task Im_hellen_Teil_kein_Fund() + { + var proben = await Abtaster().UeberblickAsync(_datei, ab: 0, dauer: 80); + + Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Null); + } + + /// Eine einzelne Sekunde lässt sich messen. + [Test] + public async Task Eine_Sekunde_im_Abspann_ist_dunkel() + { + var dunkel = await Abtaster().HelligkeitAsync(_datei, bei: 120); + var hell = await Abtaster().HelligkeitAsync(_datei, bei: 40); + + Assert.Multiple(() => + { + Assert.That(dunkel, Is.Not.Null.And.LessThan(Abspannregeln.Vorgabe.Dunkelschwelle)); + Assert.That(hell, Is.Not.Null.And.GreaterThan(Abspannregeln.Vorgabe.Dunkelschwelle)); + }); + } +} diff --git a/server/tests/Flaro.Media.Tests/SchluesselbilderTests.cs b/server/tests/Flaro.Media.Tests/SchluesselbilderTests.cs new file mode 100644 index 0000000..6f24942 --- /dev/null +++ b/server/tests/Flaro.Media.Tests/SchluesselbilderTests.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using System.Globalization; +using Flaro.Media; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Media.Tests; + +/// Sagt die Schlüsselbildsuche dasselbe wie ffmpeg? +/// +/// +/// +/// Der Fehler, für den diese Prüfung steht. Die Suche las mit +/// -read_intervals bis eine halbe Sekunde hinter den Wunsch — und +/// ffprobe hörte damit regelmässig eine ganze Bildgruppe zu früh auf. Der +/// Server meldete dem Spieler dann einen Anfang, der vier Sekunden vor dem +/// lag, an dem der Strom wirklich begann; die Untertitel kamen vier +/// Sekunden zu spät. +/// +/// +/// Gegen ffmpeg geprüft, nicht gegen eine Erwartung. Was zählt, ist +/// nicht, ob die Zahl „schön" ist, sondern ob sie mit der Stelle +/// übereinstimmt, an der der Strom tatsächlich anfängt. Die holt derselbe +/// Aufruf wie im Betrieb, nur mit -copyts, damit die Zeit absolut +/// bleibt. +/// +/// +public class SchluesselbilderTests +{ + private string _ordner = ""; + private string _datei = ""; + + /// Vier Sekunden — so kodiert, was von Streamingdiensten kommt. + private const double Bildgruppe = 4; + + [OneTimeSetUp] + public void Erzeugen() + { + _ordner = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), "flaro-keyframe-" + Guid.CreateVersion7().ToString("N"))).FullName; + _datei = Path.Combine(_ordner, "quelle.mp4"); + + var gelungen = Starten("ffmpeg", + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=120", + "-c:v", "libx264", "-preset", "ultrafast", + "-g", "100", "-pix_fmt", "yuv420p", _datei); + + if (!gelungen || !File.Exists(_datei)) + Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen."); + } + + [OneTimeTearDown] + public void Aufraeumen() + { + try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { } + } + + /// Die Stelle, an der ffmpeg beim Kopieren wirklich anfängt. + private double? EchterAnfang(double ab) + { + var ziel = Path.Combine(_ordner, "schnitt.mp4"); + var gelungen = Starten("ffmpeg", + "-hide_banner", "-loglevel", "error", "-y", + "-noaccurate_seek", "-ss", ab.ToString("0.###", CultureInfo.InvariantCulture), + "-i", _datei, "-map", "0:0", "-c", "copy", "-copyts", "-f", "mp4", ziel); + if (!gelungen) return null; + + var ausgabe = Lesen("ffprobe", + "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=start_time", "-of", "csv=p=0", ziel); + + return double.TryParse(ausgabe?.Trim(), NumberStyles.Float, + CultureInfo.InvariantCulture, out var wert) ? wert : null; + } + + /// Jeder dieser Werte lag vorher um eine ganze Bildgruppe daneben. + [TestCase(50.0)] + [TestCase(50.5)] + [TestCase(52.0)] + [TestCase(52.1)] + [TestCase(56.0)] + [TestCase(73.3)] + [TestCase(100.2)] + public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch) + { + var suche = new Schluesselbilder( + FfmpegOptions.FromEnvironment(), NullLogger.Instance); + + var gemeldet = await suche.DavorAsync(_datei, wunsch); + var echt = EchterAnfang(wunsch); + + if (echt is null) Assert.Ignore("ffmpeg lieferte keinen Anfang."); + var stelle = echt!.Value; + + Assert.That(gemeldet, Is.EqualTo(stelle).Within(0.05), + $"gemeldet {gemeldet}, tatsächlich {stelle} — die Differenz landet " + + "eins zu eins im Versatz der Untertitel"); + } + + /// Und der Fund ist nie später als der Wunsch. + [TestCase(50.5)] + [TestCase(73.3)] + public async Task Nie_hinter_dem_Wunsch(double wunsch) + { + var suche = new Schluesselbilder( + FfmpegOptions.FromEnvironment(), NullLogger.Instance); + + Assert.That(await suche.DavorAsync(_datei, wunsch), Is.LessThanOrEqualTo(wunsch)); + } + + // ---------- Werkzeug ---------- + + private static bool Starten(string programm, params string[] argumente) + { + var start = new ProcessStartInfo(programm) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in argumente) start.ArgumentList.Add(a); + + try + { + using var prozess = Process.Start(start); + if (prozess is null) return false; + prozess.WaitForExit(120_000); + return prozess.ExitCode == 0; + } + catch (Exception) { return false; } + } + + private static string? Lesen(string programm, params string[] argumente) + { + var start = new ProcessStartInfo(programm) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in argumente) start.ArgumentList.Add(a); + + try + { + using var prozess = Process.Start(start); + if (prozess is null) return null; + var text = prozess.StandardOutput.ReadToEnd(); + prozess.WaitForExit(60_000); + return prozess.ExitCode == 0 ? text : null; + } + catch (Exception) { return null; } + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 3dad764..bc75e02 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -87,6 +87,8 @@ export type Film = { libraryId: string | null; /** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */ heroImage?: string | null; + /** Der Abspann, sofern er im Bild gefunden wurde. */ + markers?: Marke[] | null; }; export type Serie = { id: string; title: string; year: number | null; overview: string | null; @@ -656,6 +658,31 @@ export const markenSuchen = (serieId: string, erneut = false) => anfrage( `/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); +/** Die Marken eines Titels — Folge oder Film. + * + * **Eine Kennung, zwei mögliche Wege.** Der Spieler bekommt nur eine + * Kennung und weiss nicht, was dahintersteckt; erst die Antwort sagt es. + * Eine 404 auf beiden Wegen heisst: keine Marken, und mehr hängt nicht + * daran. */ +export async function markenHolen(titelId: string): Promise { + const folge = await folgeLesen(titelId); + if (folge) return folge.markers ?? []; + try { + const f = await anfrage<{ markers?: Marke[] | null }>(`/movies/${titelId}`); + return f.markers ?? []; + } catch { + return []; + } +} + +/** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */ +export const filmmarkenVerwerfen = (filmId: string) => + anfrage(`/movies/${filmId}/marken`, { method: 'DELETE' }); + +/** Stösst die Abspannsuche für einen Film an. */ +export const filmmarkenSuchen = (filmId: string, erneut = false) => + anfrage(`/movies/${filmId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); + export async function folgeLesen(id: string): Promise { try { return (await anfrage(`/episodes/${id}`)) ?? null; diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 2ab257f..30556ba 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -1,6 +1,6 @@ import type HlsTyp from 'hls.js'; import { - stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen, + stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen, type Stromauskunft, type Spurauskunft, type Folge, type Marke, } from './api'; import { dauer, knopf, meldung, hinweisZeigen } from './util'; @@ -643,14 +643,14 @@ export function spieler(anfrage: Spieleranfrage): void { // je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu // zeigen, von dem noch niemand weiss, ob er etwas tut. void (async () => { - const [davor, danach, diese] = await Promise.all([ + const [davor, danach, gefunden] = await Promise.all([ nachbarfolge(anfrage.titelId, 'previous'), nachbarfolge(anfrage.titelId, 'next'), - folgeLesen(anfrage.titelId), + markenHolen(anfrage.titelId), ]); vorige = davor; naechste = danach; - marken = diese?.markers ?? []; + marken = gefunden; if (geschlossen) return; vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null); naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null); diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 09ee957..31a9d07 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -3,6 +3,7 @@ import { ApiFehler, vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen, type Film, type Serie, type Folge, type Datei, type Stand, type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen, + filmmarkenSuchen, filmmarkenVerwerfen, } from '../api'; import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, @@ -492,6 +493,34 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem // will aber genau das: noch einmal, und zwar jetzt. Ein Knopf, der je // nach gespeichertem Zustand mal etwas tut und mal nicht, wäre der // schlechtere Tausch. + // **Bei einem Film sucht der Server im Bild.** Ein Abspann verrät sich + // bei einer Serie dadurch, dass er in jeder Folge derselbe ist; ein + // Film hat nichts, womit er sich vergleichen liesse. Dort zählt, dass + // er dunkel ist und bis zum Ende durchhält — und das trifft längst + // nicht auf jeden Film zu. + if (melden && istAdmin() && art === 'filme') { + reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen', + async () => { + await filmmarkenSuchen(eintrag.id, true); + hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür ' + + 'die letzten zwanzig Minuten an — das dauert einen Augenblick.'); + // Kurz warten und nachsehen: anders als bei einer Serie ist das + // hier in Sekunden durch. + window.setTimeout(() => void laden(), 4000); + })); + + // **Nur, wenn es etwas zu verwerfen gibt.** Ein Knopf, der nichts + // wegnehmen kann, ist eine Behauptung über einen Zustand. + if ('markers' in eintrag && (eintrag.markers?.length ?? 0) > 0) { + reihe.appendChild(rundKnopf('schliessen', false, 'Abspannmarke verwerfen', + async () => { + await filmmarkenVerwerfen(eintrag.id); + hinweisZeigen('Marke entfernt. Der nächste Scan legt sie nicht wieder an.'); + await laden(); + })); + } + } + if (melden && istAdmin() && art === 'serien') { reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen', async () => {