diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index 08447e1..e3e2db1 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -127,6 +127,20 @@ enum Wire { /// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich /// nicht darstellbar: Titel und Bild der Serie fehlten. let seriesId: String? + /// Vorspann und Abspann, sofern der Server sie gefunden hat. + /// + /// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine + /// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu + /// schicken. + let markers: [Marker]? + } + + /// Ein überspringbarer Abschnitt. + struct Marker: Decodable, Sendable { + /// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin. + let kind: String + let startSeconds: Double + let endSeconds: Double } /// Eine Kachel aus „Weiterschauen" oder „Nächste Folge". diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift index b94dab1..087a072 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift @@ -72,7 +72,16 @@ extension Wire.Episode { isFavourite: state?.favourite ?? false, isInProgress: state?.inProgress, availableFrom: publishAt, - versions: [Wire.fassung(id: id, files: files)] + versions: [Wire.fassung(id: id, files: files)], + // **Vorspann und Abspann, wenn der Server sie kennt.** + // Ältere Server schicken das Feld gar nicht; dann ist die + // Liste leer und die Spieler zeigen schlicht keinen Knopf. + 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 10d5d37..5ae0ab5 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -538,6 +538,15 @@ public sealed record SeriesResponse( /// Guid? LibraryId = null); +/// Ein überspringbarer Abschnitt — Vorspann oder Abspann. +/// +/// +/// Kind ist "intro" oder "credits": dieselben Wörter, +/// die Jellyfin benutzt. Die Apps kennen sie bereits, und zwei Namen für +/// dieselbe Sache wären zwei Übersetzungen an der Grenze jeder App. +/// +public sealed record MarkerResponse(string Kind, double StartSeconds, double EndSeconds); + public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, IReadOnlyList Files, @@ -553,7 +562,16 @@ public sealed record EpisodeResponse( /// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr /// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte. /// - Guid? SeriesId = null); + Guid? SeriesId = null, + + /// Vorspann und Abspann, sofern erkannt. + /// + /// + /// Leer heisst „nicht erkannt", nicht „gibt es nicht". Wer sie sucht, + /// stösst den Lauf an; bis er durch ist, bleibt die Liste leer und die + /// Spieler zeigen schlicht keinen Knopf. + /// + IReadOnlyList? Markers = null); public sealed record Page(IReadOnlyList Items, int Total); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 80e29e9..81411b1 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -118,6 +118,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]); // **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt // nichts; das Plakat der Serie sagt wenigstens, worum es geht. @@ -133,7 +134,31 @@ public static class LibraryContentEndpoints dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(), stände.GetValueOrDefault(nachbar.Id), nachbar.PublishAt, - SeriesId: diese.SeriesId)); + SeriesId: diese.SeriesId, + Markers: marken[nachbar.Id].ToList())); + } + + /// Die Marken zu einer Reihe von Folgen. + /// + /// + /// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit + /// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen. + /// + private static async Task> MarkenZu( + FlaroContext db, IReadOnlyCollection folgen) + { + if (folgen.Count == 0) + 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 }) + .ToListAsync(); + + return roh.ToLookup( + m => m.EpisodeId, + m => new MarkerResponse( + m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden)); } private static async Task> SpurenZu(FlaroContext db, List dateien) @@ -792,6 +817,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 wurzel = await MediathekspfadFuer(db, wer, await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId) @@ -803,7 +829,8 @@ public static class LibraryContentEndpoints dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), stände.GetValueOrDefault(id), folge.PublishAt, - SeriesId: folge.SeriesId)); + SeriesId: folge.SeriesId, + Markers: marken[id].ToList())); }).RequireAuthorization(); // **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist @@ -813,6 +840,21 @@ public static class LibraryContentEndpoints // **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer // eine Folge zu weit gesprungen ist, soll zurück können, ohne über // die Serienseite zu gehen. + // **Vorspann und Abspann suchen — auf Zuruf.** + // + // 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. + app.MapPost("/api/v1/series/{id:guid}/marken", async ( + Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) => + { + if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound(); + + return erkennung.Starten(id, erneut) + ? Results.Accepted() + : Einwand.Belegt("Für diese Serie werden gerade Marken gesucht."); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) => Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization(); @@ -901,6 +943,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 serienBild = await db.Series.AsNoTracking() .Where(x => x.Id == id) @@ -913,7 +956,8 @@ public static class LibraryContentEndpoints .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), stände.GetValueOrDefault(f.Id), f.PublishAt, - SeriesId: id)).ToList(); + SeriesId: id, + Markers: marken[f.Id].ToList())).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs new file mode 100644 index 0000000..a0c116a --- /dev/null +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -0,0 +1,204 @@ +using System.Collections.Concurrent; +using Flaro.Core.Library; +using Flaro.Core.Media; +using Flaro.Data; +using Flaro.Media; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Sucht Vorspann und Abspann einer Staffel. +/// +/// +/// +/// Beides mit derselben Rechnung, nur in einem anderen Fenster. +/// 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 +/// zu haben. +/// +/// +/// Je Staffel, nicht je Serie. Serien wechseln ihren Vorspann +/// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln +/// verglichen fände man entweder nichts oder das Falsche. +/// +/// +/// Im Hintergrund, mit einem Zustand zum Nachsehen. Eine Staffel +/// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein +/// Zeitlimit zu laufen und am Ende nichts zu haben. +/// +/// +public sealed class Markenerkennung( + IServiceScopeFactory scopes, + TimeProvider clock, + ILogger log) +{ + /// Wie viel vom Anfang nach einem Vorspann abgesucht wird. + /// + /// + /// Fünfzehn Minuten. Ein Vorspann liegt fast immer in den ersten fünf; + /// manche Serie stellt aber eine Viertelstunde Handlung davor. Weiter + /// zu suchen kostet Zeit und schafft vor allem Gelegenheiten, zwei + /// zufällig ähnliche Takte für einen Vorspann zu halten. + /// + public const double Introfenster = 15 * 60; + + /// Wie viel vom Ende nach einem Abspann abgesucht wird. + public const double Abspannfenster = 10 * 60; + + /// Was ein gefundenes Stück höchstens lang sein darf. + /// + /// + /// Die wichtigste Schranke. Zwei Folgen, die dieselbe Datei + /// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne + /// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film. + /// + public const double Hoechstlaenge = 5 * 60; + + /// Welche Staffeln gerade abgetastet werden. + private readonly ConcurrentDictionary _laufend = new(); + + public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId); + + /// Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft. + public bool Starten(Guid serieId, bool erneut = false) + { + if (!_laufend.TryAdd(serieId, true)) return false; + + _ = Task.Run(async () => + { + try + { + await LaufenAsync(serieId, erneut, CancellationToken.None); + } + catch (Exception fehler) + { + log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serieId); + } + finally + { + _laufend.TryRemove(serieId, out _); + } + }); + return true; + } + + private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var leser = raum.ServiceProvider.GetRequiredService(); + + if (!await leser.VorhandenAsync(ct)) + { + log.LogInformation( + "Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden."); + return; + } + + // Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den + // Abgleich unbrauchbar: ohne Datei nichts zu lesen, ohne Laufzeit + // kein Fenster fürs Ende. + var folgen = await db.Episodes.AsNoTracking() + .Where(f => f.SeriesId == serieId) + .Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId, + (f, d) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, d.Path, d.Duration }) + .ToListAsync(ct); + + var vorhanden = await db.Marken.AsNoTracking() + .Where(m => folgen.Select(f => f.Id).Contains(m.EpisodeId)) + .Select(m => new { m.EpisodeId, m.Art }) + .ToListAsync(ct); + + var gefunden = 0; + foreach (var staffel in folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key)) + { + var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList(); + if (geordnet.Count < 2) continue; + + foreach (var art in new[] { Markenart.Intro, Markenart.Abspann }) + { + // Schon gefunden? Dann nicht noch einmal rechnen — es sei + // denn, jemand verlangt es ausdrücklich. + if (!erneut && geordnet.All(f => + vorhanden.Any(v => v.EpisodeId == f.Id && v.Art == art))) + continue; + + var abdruecke = new List<(Guid Schlüssel, IReadOnlyList Abdruck)>(); + var fensteranfang = new Dictionary(); + + foreach (var folge in geordnet) + { + var laufzeit = folge.Duration?.TotalSeconds ?? 0; + var (ab, dauer) = Fenster(art, laufzeit); + if (dauer <= 0) continue; + + var abdruck = await leser.LesenAsync(folge.Path, ab, dauer, ct); + if (abdruck.Count == 0) continue; + + abdruecke.Add((folge.Id, abdruck)); + fensteranfang[folge.Id] = ab; + } + + if (abdruecke.Count < 2) continue; + + var treffer = Tonabgleich.ReiheAbgleichen( + abdruecke, Tonabdruckleser.SekundenJeAbdruck); + + foreach (var (folgenId, stelle) in treffer) + { + if (stelle.Bis - stelle.Von > Hoechstlaenge) continue; + + // **Die Zeiten liegen im Fenster, nicht in der Folge.** + // Beim Abspann fängt das Fenster zehn Minuten vor dem + // Ende an; ohne diesen Aufschlag stünde der Knopf bei + // Minute zwei. + var versatz = fensteranfang.GetValueOrDefault(folgenId); + if (await SchreibenAsync(db, folgenId, art, + stelle.Von + versatz, stelle.Bis + versatz, ct)) + gefunden++; + } + } + } + + if (gefunden > 0) await db.SaveChangesAsync(ct); + log.LogInformation("Markenerkennung für {Serie}: {Anzahl} Marken.", serieId, gefunden); + } + + /// Welcher Ausschnitt der Folge abgetastet wird. + private static (double Ab, double Dauer) Fenster(Markenart art, double laufzeit) + { + if (art == Markenart.Intro) return (0, laufzeit > 0 ? Math.Min(Introfenster, laufzeit) : Introfenster); + + // Ohne bekannte Laufzeit lässt sich das Ende nicht finden. + if (laufzeit <= 0) return (0, 0); + var ab = Math.Max(0, laufzeit - Abspannfenster); + return (ab, laufzeit - ab); + } + + private async Task SchreibenAsync( + FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct) + { + var alt = await db.Marken + .FirstOrDefaultAsync(m => m.EpisodeId == folgenId && m.Art == art, ct); + + if (alt is not null) + { + alt.VonSekunden = von; + alt.BisSekunden = bis; + alt.ErkanntAm = clock.GetUtcNow(); + return true; + } + + db.Marken.Add(new Marke + { + EpisodeId = folgenId, + Art = art, + VonSekunden = von, + BisSekunden = bis, + ErkanntAm = clock.GetUtcNow(), + }); + return true; + } +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 1ff26ec..fefaecf 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -170,6 +170,10 @@ builder.Services.AddScoped(sp => sp.GetRequiredService builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den +// Vermerk, welche Serie gerade abgetastet wird. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei diff --git a/server/src/Flaro.Core/Library/CatalogEntities.cs b/server/src/Flaro.Core/Library/CatalogEntities.cs index c142ac2..78aeb27 100644 --- a/server/src/Flaro.Core/Library/CatalogEntities.cs +++ b/server/src/Flaro.Core/Library/CatalogEntities.cs @@ -123,3 +123,45 @@ public sealed class Stichwortlink public Guid? MovieId { get; init; } public Guid? SeriesId { get; init; } } + +/// Was für ein Abschnitt eine Marke bezeichnet. +public enum Markenart +{ + /// Der Vorspann. + Intro, + /// Der Abspann. + Abspann, +} + +/// Ein überspringbarer Abschnitt einer Folge. +/// +/// +/// +/// Nur bei Folgen, nicht bei Filmen. Erkannt wird ein Vorspann +/// dadurch, dass er sich wiederholt — über die Folgen einer Staffel hinweg. +/// Ein Film hat nichts, womit er sich vergleichen liesse, und sein Abspann +/// ist nicht derselbe wie der eines anderen Films. +/// +/// +/// Zeiten in Sekunden, nicht in Bildern. Dieselbe Folge liegt +/// vielleicht in zwei Fassungen mit verschiedener Bildrate vor; die Stelle +/// im Film ist dieselbe. +/// +/// +public sealed class Marke +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid EpisodeId { get; init; } + public required Markenart Art { get; init; } + public required double VonSekunden { get; set; } + public required double BisSekunden { get; set; } + + /// Wann sie gefunden wurde. + /// + /// + /// Damit ein späterer Lauf weiss, was er nicht noch einmal rechnen muss. + /// Eine Staffel abzutasten dauert Minuten; sie bei jedem Scan erneut + /// abzutasten wäre eine Dauerlast ohne Ertrag. + /// + public DateTimeOffset ErkanntAm { get; set; } +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 3104293..472c2ca 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -76,6 +76,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Genres => Set(); public DbSet GenreLinks => Set(); public DbSet Fremdbewertungen => Set(); + public DbSet Marken => Set(); public DbSet Stichworte => Set(); public DbSet Stichwortlinks => Set(); public DbSet People => Set(); @@ -306,6 +307,18 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.Anzeige).HasMaxLength(32); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + // Eine Art einmal je Folge: ein zweiter Lauf schreibt um, statt + // eine zweite Zeile anzulegen. + e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique(); + e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).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(); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/Migrations/20261012191546_Marken.Designer.cs b/server/src/Flaro.Data/Migrations/20261012191546_Marken.Designer.cs new file mode 100644 index 0000000..9f1b293 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261012191546_Marken.Designer.cs @@ -0,0 +1,1293 @@ +// +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("20261012191546_Marken")] + partial class Marken + { + /// + 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("VonSekunden") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Art") + .IsUnique(); + + b.ToTable("Marken"); + }); + + 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("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("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) + .IsRequired(); + }); + + 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/20261012191546_Marken.cs b/server/src/Flaro.Data/Migrations/20261012191546_Marken.cs new file mode 100644 index 0000000..56bc5c4 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261012191546_Marken.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Marken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Marken", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EpisodeId = table.Column(type: "TEXT", nullable: false), + Art = table.Column(type: "INTEGER", nullable: false), + VonSekunden = table.Column(type: "REAL", nullable: false), + BisSekunden = table.Column(type: "REAL", nullable: false), + ErkanntAm = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Marken", x => x.Id); + table.ForeignKey( + name: "FK_Marken_Episodes_EpisodeId", + column: x => x.EpisodeId, + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Marken_EpisodeId_Art", + table: "Marken", + columns: new[] { "EpisodeId", "Art" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Marken"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index d9ac304..c2c3e26 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -406,6 +406,35 @@ namespace Flaro.Data.Migrations 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("VonSekunden") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Art") + .IsUnique(); + + b.ToTable("Marken"); + }); + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => { b.Property("Id") @@ -1125,6 +1154,15 @@ 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) + .IsRequired(); + }); + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => { b.HasOne("Flaro.Core.Libraries.Library", null) diff --git a/server/tests/Flaro.Api.Tests/FlaroServer.cs b/server/tests/Flaro.Api.Tests/FlaroServer.cs index 84dcc05..adac444 100644 --- a/server/tests/Flaro.Api.Tests/FlaroServer.cs +++ b/server/tests/Flaro.Api.Tests/FlaroServer.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using Flaro.Api.Endpoints; using Microsoft.AspNetCore.Hosting; +using Flaro.Data; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -25,6 +26,20 @@ public sealed class FlaroServer : IDisposable public HttpClient Client { get; } + /// Ein Blick in die Datenbank — für das, was kein Endpunkt anlegt. + /// + /// + /// Sparsam benutzen. Ein Test, der am Server vorbei schreibt, + /// prüft nicht mehr, ob der Server es könnte. Für Marken ist es nötig: + /// sie entstehen aus einem Tonabgleich, und der braucht Mediendateien + /// mit echtem Ton, die es hier nicht gibt. + /// + public async Task InDerDatenbank(Func tun) + { + using var raum = _fabrik.Services.CreateScope(); + await tun(raum.ServiceProvider.GetRequiredService()); + } + /// /// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test /// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter diff --git a/server/tests/Flaro.Api.Tests/MarkenTests.cs b/server/tests/Flaro.Api.Tests/MarkenTests.cs new file mode 100644 index 0000000..8954f19 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/MarkenTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; +using Flaro.Core.Library; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Tests; + +/// Vorspann und Abspann auf dem Weg zu den Spielern. +/// +/// +/// Ohne Tonabgleich. Ob er findet, was er finden soll, steht in +/// TonabgleichTests — mit erfundenen Abdrücken und ohne Mediendateien. +/// Hier geht es um das Stück danach: dass eine gefundene Marke auch bei dem +/// ankommt, der den Knopf zeichnen soll. +/// +public class MarkenTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + + private async Task Eingelesen(FlaroServer server, string art) + { + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", art, _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State is "finished") return client; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig."); + return null!; + } + + private static async Task<(Guid Serie, Guid Folge)> MitEinerFolge(HttpClient client) + { + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0].Id; + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie}/episodes"); + return (serie, folgen!.Items[0].Id); + } + + [Test] + public async Task Eine_Marke_kommt_bei_der_Folge_an() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + var (_, folge) = await MitEinerFolge(client); + + await server.InDerDatenbank(async db => + { + db.Marken.Add(new Marke + { + EpisodeId = folge, Art = Markenart.Intro, + VonSekunden = 30, BisSekunden = 65, ErkanntAm = DateTimeOffset.UtcNow, + }); + db.Marken.Add(new Marke + { + EpisodeId = folge, Art = Markenart.Abspann, + VonSekunden = 2500, BisSekunden = 2600, ErkanntAm = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var einzeln = await client.GetFromJsonAsync($"/api/v1/episodes/{folge}"); + var intro = einzeln!.Markers!.Single(m => m.Kind == "intro"); + + Assert.Multiple(() => + { + Assert.That(einzeln.Markers, Has.Count.EqualTo(2)); + Assert.That(intro.StartSeconds, Is.EqualTo(30)); + Assert.That(intro.EndSeconds, Is.EqualTo(65)); + Assert.That(einzeln.Markers!.Any(m => m.Kind == "credits"), Is.True, + "der Abspann heisst gegenüber den Apps credits"); + }); + } + + /// Auch in der Folgenliste — dort holt die App sie beim Blättern. + [Test] + public async Task Die_Folgenliste_bringt_die_Marken_mit() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + var (serie, folge) = await MitEinerFolge(client); + + await server.InDerDatenbank(async db => + { + db.Marken.Add(new Marke + { + EpisodeId = folge, Art = Markenart.Intro, + VonSekunden = 10, BisSekunden = 40, ErkanntAm = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var liste = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie}/episodes"); + + Assert.Multiple(() => + { + Assert.That(liste!.Items.Single(f => f.Id == folge).Markers, Has.Count.EqualTo(1)); + Assert.That(liste.Items.Single(f => f.Id != folge).Markers, Is.Empty, + "eine Folge ohne Marke bekommt eine leere Liste, nicht die der anderen"); + }); + } + + /// Ohne erkannte Marken bleibt die Liste leer — kein Knopf, kein Fehler. + [Test] + public async Task Ohne_Erkennung_ist_die_Liste_leer() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + var (_, folge) = await MitEinerFolge(client); + + var einzeln = await client.GetFromJsonAsync($"/api/v1/episodes/{folge}"); + Assert.That(einzeln!.Markers, Is.Empty); + } + + /// Den Lauf darf nur ein Verwalter anstossen. + [Test] + public async Task Die_Suche_laesst_sich_anstossen() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + + var antwort = await client.PostAsync($"/api/v1/series/{serien!.Items[0].Id}/marken", null); + var unbekannt = await client.PostAsync($"/api/v1/series/{Guid.NewGuid()}/marken", null); + + Assert.Multiple(() => + { + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 898205d..c76acf5 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -113,12 +113,17 @@ export type Serie = { * Das Auffrischen hängt an der Mediathek, nicht am Titel. */ libraryId: string | null; }; +/** Ein überspringbarer Abschnitt einer Folge. */ +export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number }; + export type Folge = { id: string; season: number; episode: number; title: string | null; overview: string | null; stillImage: string | null; files: Datei[]; state: Stand | null; /** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */ seriesId?: string | null; + /** Vorspann und Abspann, sofern der Server sie gefunden hat. */ + markers?: Marke[] | null; /** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */ publishAt?: string | null; }; @@ -632,6 +637,27 @@ export const naechsteFolgen = (take = 20) => * „das ist eine Folge nicht" (ein Film etwa), eine 204 „es gibt keine * weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem * laufenden Film öffnen. */ +/** Eine Folge für sich — tolerant, wie die Nachbarn. + * + * Für den Spieler: er bekommt nur eine Kennung und möchte wissen, ob es + * dazu Marken gibt. Eine 404 heisst „kein Folgentitel", und mehr hängt + * nicht daran. */ +/** Stösst die Suche nach Vorspann und Abspann einer Serie an. + * + * Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge + * einer Staffel ein Stück Ton —, und darauf wartet niemand. */ +export const markenSuchen = (serieId: string, erneut = false) => + anfrage( + `/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); + +export async function folgeLesen(id: string): Promise { + try { + return (await anfrage(`/episodes/${id}`)) ?? null; + } catch { + return null; + } +} + export async function nachbarfolge( folgeId: string, richtung: 'next' | 'previous', ): Promise { diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 2354a29..2ab257f 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -1,7 +1,7 @@ import type HlsTyp from 'hls.js'; import { - stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, - type Stromauskunft, type Spurauskunft, type Folge, + stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen, + type Stromauskunft, type Spurauskunft, type Folge, type Marke, } from './api'; import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { serienton, serientext, spurFuer } from './sprachwahl'; @@ -600,6 +600,8 @@ export function spieler(anfrage: Spieleranfrage): void { /** Die Nachbarfolgen, sobald der Server sie genannt hat. */ let vorige: Folge | null = null; let naechste: Folge | null = null; + /** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */ + let marken: Marke[] = []; // **Eine Folge weiter, eine zurück.** // @@ -627,16 +629,28 @@ export function spieler(anfrage: Spieleranfrage): void { unten.className = 'pl-unten'; unten.append(links, zeitmitte, rechts); - bedienung.append(zeitleiste, unten); + // **„Intro überspringen" über der Leiste, rechtsbündig.** + // + // Dort erwartet man ihn — so macht es jeder Spieler —, und dort verdeckt + // er weder das Bild noch die Zeitleiste. Er steht im selben Block wie die + // Bedienung und blendet mit ihr ein und aus. + const ueberspringen = document.createElement('button'); + ueberspringen.className = 'btn pl-ueberspringen hidden'; + + bedienung.append(ueberspringen, zeitleiste, unten); // **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal // je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu // zeigen, von dem noch niemand weiss, ob er etwas tut. void (async () => { - [vorige, naechste] = await Promise.all([ + const [davor, danach, diese] = await Promise.all([ nachbarfolge(anfrage.titelId, 'previous'), nachbarfolge(anfrage.titelId, 'next'), + folgeLesen(anfrage.titelId), ]); + vorige = davor; + naechste = danach; + marken = diese?.markers ?? []; if (geschlossen) return; vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null); naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null); @@ -741,6 +755,23 @@ export function spieler(anfrage: Spieleranfrage): void { kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt); kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`; + + markenstand(jetzt); + } + + /** Zeigt den Überspringen-Knopf, solange man in einer Marke steht. + * + * **Keine eigene Uhr.** Die Zeit wird ohnehin bei jedem Fortschreiten + * nachgeführt; ein zweiter Zeitgeber dafür liefe neben dem ersten her und + * wäre eine zweite Stelle, an der etwas hängen bleibt. */ + function markenstand(jetzt: number): void { + const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds); + ueberspringen.classList.toggle('hidden', !drin); + if (!drin) return; + + const text = drin.kind === 'intro' ? 'Intro überspringen' : 'Abspann überspringen'; + if (ueberspringen.textContent !== text) ueberspringen.textContent = text; + ueberspringen.onclick = e => { e.stopPropagation(); void springen(drin.endSeconds); }; } function umschalten(): void { diff --git a/web/app/src/style.css b/web/app/src/style.css index 4e2ad9e..ab78012 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -918,6 +918,16 @@ textarea.feld { * links und rechts übrig bleibt. Mit `space-between` wanderte der * Abspielknopf, sobald rechts ein Menü dazukam oder links die Lautstärke * wegfiel. `1fr auto 1fr` hält ihn fest. */ +/* „Intro überspringen" — rechtsbündig über der Zeitleiste. + * + * Als eigene Zeile im Bedienblock, nicht als Überzug über dem Bild: so + * blendet er mit der Bedienung ein und aus und verdeckt nie die Leiste. */ +.pl-ueberspringen { + align-self: flex-end; + margin: 0 0 2px auto; + display: block; +} + .pl-unten { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: var(--s-regular); diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 715e99d..7990c81 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -2,7 +2,7 @@ import { ApiFehler, film as filmLaden, serie as serieLaden, folgen, vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen, type Film, type Serie, type Folge, type Datei, type Stand, - type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, + type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen, } from '../api'; import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, @@ -476,6 +476,23 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem })); } + // **Vorspann und Abspann suchen.** + // + // Nur bei einer Serie: erkannt wird ein Vorspann dadurch, dass er sich + // über die Folgen einer Staffel wiederholt. Ein Film hat nichts, womit + // er sich vergleichen liesse. + // + // Auf Zuruf und nicht von selbst: der Lauf liest von jeder Folge ein + // Stück Ton, und das dauert Minuten. Ihn bei jedem Scan mitlaufen zu + // lassen hiesse, jeden Scan um ein Vielfaches zu verlängern. + if (melden && istAdmin() && art === 'serien') { + reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann suchen', + async () => { + await markenSuchen(eintrag.id); + hinweisZeigen('Vorspann und Abspann werden gesucht. Das dauert ein paar Minuten.'); + })); + } + // **Bearbeiten — und damit die Bilder — von hier aus erreichbar.** // // Den Dialog gab es längst, aber nur am Dreipunktmenü einer Kachel und