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