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