Files
Flaro/server/src/Flaro.Data/FlaroContext.cs
T
ScarriffleandClaude Opus 5 7673c73757 Vier Sekunden, die ffprobe zu früh aufhörte zu lesen
Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 06:42:16 +02:00

528 lines
26 KiB
C#

using Flaro.Core;
using Flaro.Core.Auth;
using Flaro.Core.Libraries;
using Flaro.Core.Library;
using Flaro.Core.Playback;
using Flaro.Core.Users;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Data;
/// <summary>Die Datenbank des Servers.</summary>
///
/// <remarks>
/// <b>SQLite, eine Datei neben der Konfiguration.</b> Kein zweiter Container,
/// kein Passwort, Sicherung ist Kopieren — bei selbstgehosteter Software ist
/// jede zusätzliche Einrichtungshürde eine messbare Abbruchquote.
///
/// Der Preis steht im Entwurfsdokument: Schreibzugriffe laufen nacheinander.
/// Bei einem Haushalt ist das belanglos, bei 40 000 Titeln und laufendem Scan
/// nicht — dann ist der Umstieg auf PostgreSQL fällig, und deshalb steht hier
/// nirgends SQLite-eigenes SQL.
/// </remarks>
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
{
/// <summary>Ob diese Anfrage auch Unveröffentlichtes sehen darf.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier und nicht in zwanzig Abfragen.</b> Auf Filme und Folgen wird an
/// rund zwanzig Stellen in vier Dateien zugegriffen. Schriebe jede ihren
/// eigenen Filter, reichte eine vergessene — und auffallen würde es nicht
/// beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht,
/// den er nicht sehen sollte. Ein globaler Abfragefilter lässt sich nicht
/// vergessen.
/// </para>
/// <para>
/// <b>Vorgabe <c>true</c>, gesetzt wird je Anfrage.</b> Der Scanner und
/// die Metadaten laufen ohne Benutzer und müssen alles sehen — sonst legt
/// der nächste Lauf einen wartenden Titel ein zweites Mal an. Für jede
/// HTTP-Anfrage setzt die Middleware den Wert, bevor irgendein Endpunkt
/// läuft; dass sie das tut, hält ein Test fest.
/// </para>
/// </remarks>
public bool AuchUnveroeffentlichtes { get; set; } = true;
/// <summary>Der Zeitpunkt, gegen den geplante Titel geprüft werden.</summary>
///
/// <remarks>
/// Am Kontext und nicht als <c>DateTimeOffset.UtcNow</c> im Filter: ein
/// Aufruf mitten im Ausdruck liesse sich nicht nach SQL übersetzen, und
/// in Tests wäre die Zeit nicht zu stellen.
/// </remarks>
public DateTimeOffset Jetzt { get; set; } = DateTimeOffset.UtcNow;
public DbSet<User> Users => Set<User>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
/// <summary>Links, mit denen sich jemand selbst ein Konto anlegt.</summary>
public DbSet<Einladung> Einladungen => Set<Einladung>();
public DbSet<Library> Libraries => Set<Library>();
public DbSet<ServerSetting> Settings => Set<ServerSetting>();
/// <summary>Wie lange an welchem Tag gesehen wurde — siehe <see cref="Sehtag"/>.</summary>
public DbSet<Sehtag> Sehtage => Set<Sehtag>();
/// <summary>Was Benutzer an Titeln gemeldet haben — siehe <see cref="Meldung"/>.</summary>
public DbSet<Meldung> Meldungen => Set<Meldung>();
public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
public DbSet<Movie> Movies => Set<Movie>();
public DbSet<Series> Series => Set<Series>();
public DbSet<Episode> Episodes => Set<Episode>();
public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Marke> Marken => Set<Marke>();
public DbSet<Markensuche> Markensuchen => Set<Markensuche>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>();
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
///
/// <remarks>
/// Hier und nicht je Eigenschaft: eine vergessene Eigenschaft wäre eine,
/// deren Vergleich zur Laufzeit scheitert — und zwar erst dann, wenn sie
/// zum ersten Mal in einer Abfrage auftaucht.
/// </remarks>
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.Properties<DateTimeOffset>().HaveConversion<DateTimeOffsetToUnixMilliseconds>();
builder.Properties<DateTimeOffset?>().HaveConversion<NullableDateTimeOffsetToUnixMilliseconds>();
builder.Properties<TimeSpan>().HaveConversion<TimeSpanToMilliseconds>();
builder.Properties<TimeSpan?>().HaveConversion<NullableTimeSpanToMilliseconds>();
}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<User>(e =>
{
e.HasKey(x => x.Id);
// Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion.
e.HasIndex(x => x.UserName).IsUnique();
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.PasswordHash).HasMaxLength(256);
// Ein Hash aus 64 Hexzeichen, wie jeder Bildschlüssel.
e.Property(x => x.AvatarImage).HasMaxLength(64);
});
b.Entity<DeviceToken>(e =>
{
e.HasKey(x => x.Id);
// Der Weg, den jede einzelne Anfrage geht — ohne Index wäre er ein
// vollständiger Tabellendurchlauf je Aufruf.
e.HasIndex(x => x.TokenFingerprint).IsUnique();
e.HasIndex(x => x.UserId);
e.Property(x => x.TokenFingerprint).HasMaxLength(64);
e.Property(x => x.DeviceName).HasMaxLength(128);
e.Property(x => x.DeviceId).HasMaxLength(128);
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<AuthCode>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Code);
e.HasIndex(x => x.HandleFingerprint).IsUnique();
e.Property(x => x.Code).HasMaxLength(16);
e.Property(x => x.HandleFingerprint).HasMaxLength(64);
e.Property(x => x.DeviceName).HasMaxLength(128);
e.Property(x => x.DeviceId).HasMaxLength(128);
});
b.Entity<Einladung>(e =>
{
e.HasKey(x => x.Id);
// Gesucht wird nur über den Fingerabdruck — und eindeutig, damit
// zwei Links niemals dasselbe Konto füttern können.
e.HasIndex(x => x.TokenFingerprint).IsUnique();
e.Property(x => x.TokenFingerprint).HasMaxLength(64);
// Die Liste in der Verwaltung: die neuesten zuerst.
e.HasIndex(x => x.CreatedAt);
// Wer einlädt und später gelöscht wird, nimmt seine offenen
// Einladungen mit. Ein Link ohne Absender ist keiner.
e.HasOne<User>().WithMany().HasForeignKey(x => x.CreatedByUserId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Library>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.Path).HasMaxLength(1024);
// Als Text und nicht als Zahl: wer in die Datenbank sieht, soll
// „Movies" lesen und nicht „0" deuten müssen.
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
});
b.Entity<ServerSetting>(e =>
{
e.HasKey(x => x.Key);
e.Property(x => x.Key).HasMaxLength(128);
e.Property(x => x.Value).HasMaxLength(4096);
});
b.Entity<MediaFile>(e =>
{
e.HasKey(x => x.Id);
// Ein Pfad je Bibliothek, und zwar zwingend: ohne diese Regel legt
// ein zweimal angestossener Scan jede Datei zweimal an, und die
// Mediathek wächst bei jedem Lauf.
e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique();
e.Property(x => x.VersionLabel).HasMaxLength(64);
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.EpisodeId);
e.Property(x => x.Path).HasMaxLength(1024);
e.Property(x => x.Container).HasMaxLength(32);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Movie>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
// **Ein Film kommt je Mediathek genau einmal vor.**
//
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
// entstehen zwei Einträge, die anschliessend beide demselben
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
// unmöglich macht, statt nur Code, der sie einhalten soll.
//
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
// ein ungefilterter Index dann nur einen einzigen zuliesse.
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
// Gefragt wird danach genau einmal je Ansicht — aber über den
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Series>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
// **Eine Serie kommt je Mediathek genau einmal vor.**
//
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
// entstehen zwei Einträge, die anschliessend beide demselben
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
// unmöglich macht, statt nur Code, der sie einhalten soll.
//
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
// ein ungefilterter Index dann nur einen einzigen zuliesse.
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
// Gefragt wird danach genau einmal je Ansicht — aber über den
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity<MediaStream>(e =>
{
e.HasKey(x => x.Id);
// Eine Spurnummer gibt es je Datei genau einmal. Ohne diese Regel
// legt ein zweimal untersuchter Film jede Spur doppelt an.
e.HasIndex(x => new { x.MediaFileId, x.Index }).IsUnique();
e.Property(x => x.Codec).HasMaxLength(64);
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.Title).HasMaxLength(256);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Genre>(e =>
{
e.HasKey(x => x.Id);
// Ein Name genau einmal. „Science Fiction" zweimal in der Tabelle
// hiesse: ein Filter, der die Hälfte übersieht.
e.HasIndex(x => x.Name).IsUnique();
e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE");
});
b.Entity<Stichwort>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Name).IsUnique();
e.Property(x => x.Name).HasMaxLength(100);
});
b.Entity<Stichwortlink>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.StichwortId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.StichwortId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.SeriesId);
e.HasOne<Stichwort>().WithMany().HasForeignKey(x => x.StichwortId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Fremdbewertung>(e =>
{
e.HasKey(x => x.Id);
// Eine Quelle einmal je Titel: ein zweiter Lauf schreibt um,
// statt eine zweite Zeile anzulegen.
e.HasIndex(x => new { x.MovieId, x.Quelle }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.SeriesId, x.Quelle }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.Property(x => x.Quelle).HasMaxLength(32);
e.Property(x => x.Anzeige).HasMaxLength(32);
});
b.Entity<Marke>(e =>
{
e.HasKey(x => x.Id);
// Eine Art einmal je Titel: ein zweiter Lauf schreibt um, statt
// eine zweite Zeile anzulegen.
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<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().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<int>();
});
b.Entity<Markensuche>(e =>
{
e.HasKey(x => x.Id);
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<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.Property(x => x.Art).HasConversion<int>();
});
b.Entity<GenreLink>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.GenreId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.GenreId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.SeriesId);
e.HasOne<Genre>().WithMany().HasForeignKey(x => x.GenreId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Person>(e =>
{
e.HasKey(x => x.Id);
// Über die Kennung der Quelle, nicht über den Namen: es gibt zwei
// Schauspieler namens „Chris Evans".
e.HasIndex(x => x.TmdbId).IsUnique();
e.Property(x => x.Name).HasMaxLength(256);
});
b.Entity<Credit>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.SeriesId);
e.HasIndex(x => x.PersonId);
e.Property(x => x.Character).HasMaxLength(256);
e.Property(x => x.Role).HasConversion<string>().HasMaxLength(16);
e.HasOne<Person>().WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
// **Die Freigaberegel, einmal und für jede Abfrage.**
//
// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
// auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
// einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
// Entscheidung gefallen: er kommt, nur noch nicht. Den zu verstecken
// hiesse, eine Vorfreude zu unterschlagen, die der Sinn des Planens
// ist — bei Netflix steht „Ab 14. März" auf der Kachel, und genau das
// soll hier auch stehen.
//
// <b>Sichtbar heisst nicht abspielbar.</b> Dass die Datei vor dem
// Zeitpunkt nicht herausgeht, steht nicht hier, sondern bei der
// Auslieferung — ein Filter, der etwas zeigt, darf nicht zugleich der
// sein, der es herausgibt.
//
// Gerechnet statt gespeichert: damit stimmt es nach einem Neustart,
// nach einer Zeitumstellung und auch dann, wenn der Server zum
// geplanten Zeitpunkt gar nicht lief.
b.Entity<Movie>().HasQueryFilter(m =>
AuchUnveroeffentlichtes || m.PublishAt != null);
b.Entity<Episode>().HasQueryFilter(f =>
AuchUnveroeffentlichtes || f.PublishAt != null);
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
// warten, wäre ein leerer Eintrag in der Mediathek.
b.Entity<Series>().HasQueryFilter(s =>
AuchUnveroeffentlichtes
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
b.Entity<ItemTranslation>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.Tagline).HasMaxLength(512);
// Je Titel und Sprache genau eine Zeile — sonst sammeln sich
// Übersetzungen an, und welche gezeigt wird, entscheidet der Zufall.
e.HasIndex(x => new { x.MovieId, x.Language }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.SeriesId, x.Language }).IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => new { x.EpisodeId, x.Language }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
});
b.Entity<UserItemState>(e =>
{
e.HasKey(x => x.Id);
// **Je Benutzer und Titel genau eine Zeile.** Ohne diese Regel
// legen zwei Fortschrittsmeldungen, die sich überholen, zwei
// Zeilen an — und welche davon später gelesen wird, entscheidet
// der Zufall.
//
// Gefiltert, weil die jeweils andere Spalte leer ist: eine
// ungefilterte Regel über (UserId, MovieId) liesse zwar beliebig
// viele Folgen-Zeilen zu (NULL gilt als verschieden), aber das
// wäre Verlass auf eine Eigenheit statt auf eine Aussage.
e.HasIndex(x => new { x.UserId, x.MovieId }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.SeriesId }).IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
// Der Weg von „Weiterschauen": die offenen Zeilen eines
// Benutzers, neueste zuerst. Ohne Index ein vollständiger
// Durchlauf je Aufruf der Startseite.
e.HasIndex(x => new { x.UserId, x.InProgress, x.UpdatedAt });
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet ein Film aus der Mediathek, verschwindet auch der
// Stand dazu. Ein Fortschritt zu einem Titel, den es nicht mehr
// gibt, ist keine Erinnerung, sondern Datenmüll.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Sehtag>(e =>
{
e.HasKey(x => x.Id);
// Je Benutzer, Tag und Titel eine Zeile — sonst addierte sich
// derselbe Nachmittag in zwei Zeilen, und keine davon wäre falsch.
e.HasIndex(x => new { x.UserId, x.Tag, x.MovieId }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.Tag, x.EpisodeId }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
// Der Weg jeder Auswertung: die Tage eines Benutzers in einem
// Zeitraum. Ohne Index ein vollständiger Durchlauf je Aufruf.
e.HasIndex(x => new { x.UserId, x.Tag });
// **Als Zahl, nicht als Text.** Aus demselben Grund wie bei den
// Zeitpunkten: „2026-10-6" sortierte sich als Text vor
// „2026-10-31", und eine Wochenkurve stünde durcheinander.
e.Property(x => x.Tag).HasConversion(
tag => tag.DayNumber, zahl => DateOnly.FromDayNumber(zahl));
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet der Titel, verschwindet seine Sehzeit mit. Eine
// Statistik über Filme, die es nicht mehr gibt, zählt Stunden auf
// nichts.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Meldung>(e =>
{
e.HasKey(x => x.Id);
// **Als Text, nicht als Zahl.** Eine 2 in der Datenbank sagt keinem
// Menschen etwas, und eine später eingeschobene Art machte aus
// jedem gespeicherten „Ton" stillschweigend „Untertitel".
e.Property(x => x.Art).HasConversion<string>().HasMaxLength(32);
// Zweitausend Zeichen: Platz für eine Schilderung mit Zeiten und
// Fassungen, zu wenig für einen Aufsatz. Zum Vergleich: „Der
// deutsche Ton setzt nach einer halben Minute aus, Englisch läuft
// weiter" sind 72.
e.Property(x => x.Kommentar).HasMaxLength(2000);
// Der Weg der Verwaltung: die offenen, älteste zuerst.
e.HasIndex(x => new { x.ErledigtAm, x.GemeldetAm });
// Der Weg des Melders: seine eigenen, neueste zuerst.
e.HasIndex(x => new { x.UserId, x.GemeldetAm });
// **Kein eindeutiger Index.** Zwei Meldungen zu demselben Film sind
// zwei Meldungen; sie zusammenzulegen hiesse, die zweite zu
// verschlucken.
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet der Titel, verschwindet die Meldung dazu. Eine
// Aufgabe zu einem Film, den es nicht mehr gibt, ist keine mehr.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Episode>(e =>
{
e.HasKey(x => x.Id);
// Eine Folgennummer gibt es in einer Staffel genau einmal. Liegen
// zwei Dateien dafür vor, sind es zwei Fassungen derselben Folge —
// und die hängen beide an diesem einen Eintrag.
e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique();
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
}
}