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;
/// Die Datenbank des Servers.
///
///
/// SQLite, eine Datei neben der Konfiguration. 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.
///
public sealed class FlaroContext(DbContextOptions options) : DbContext(options)
{
/// Ob diese Anfrage auch Unveröffentlichtes sehen darf.
///
///
///
/// Hier und nicht in zwanzig Abfragen. 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.
///
///
/// Vorgabe true, gesetzt wird je Anfrage. 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.
///
///
public bool AuchUnveroeffentlichtes { get; set; } = true;
/// Der Zeitpunkt, gegen den geplante Titel geprüft werden.
///
///
/// Am Kontext und nicht als DateTimeOffset.UtcNow im Filter: ein
/// Aufruf mitten im Ausdruck liesse sich nicht nach SQL übersetzen, und
/// in Tests wäre die Zeit nicht zu stellen.
///
public DateTimeOffset Jetzt { get; set; } = DateTimeOffset.UtcNow;
public DbSet Users => Set();
public DbSet DeviceTokens => Set();
public DbSet AuthCodes => Set();
/// Links, mit denen sich jemand selbst ein Konto anlegt.
public DbSet Einladungen => Set();
public DbSet Libraries => Set();
public DbSet Settings => Set();
/// Wie lange an welchem Tag gesehen wurde — siehe .
public DbSet Sehtage => Set();
/// Was Benutzer an Titeln gemeldet haben — siehe .
public DbSet Meldungen => Set();
public DbSet MediaFiles => Set();
public DbSet Movies => Set();
public DbSet Series => Set();
public DbSet Episodes => Set();
public DbSet MediaStreams => Set();
public DbSet UserItemStates => Set();
public DbSet Genres => Set();
public DbSet GenreLinks => Set();
public DbSet People => Set();
public DbSet Credits => Set();
public DbSet Translations => Set();
/// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.
///
///
/// 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.
///
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.Properties().HaveConversion();
builder.Properties().HaveConversion();
builder.Properties().HaveConversion();
builder.Properties().HaveConversion();
}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity(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(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().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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(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().WithMany().HasForeignKey(x => x.CreatedByUserId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().HasMaxLength(32);
});
b.Entity(e =>
{
e.HasKey(x => x.Key);
e.Property(x => x.Key).HasMaxLength(128);
e.Property(x => x.Value).HasMaxLength(4096);
});
b.Entity(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().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().HasMaxLength(16);
e.HasOne().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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(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().WithMany().HasForeignKey(x => x.GenreId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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(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().HasMaxLength(16);
e.HasOne().WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
// **Die Freigaberegel, einmal und für jede Abfrage.**
//
// Zwei Zustände, nicht einer. 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.
//
// Sichtbar heisst nicht abspielbar. 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().HasQueryFilter(m =>
AuchUnveroeffentlichtes || m.PublishAt != null);
b.Entity().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().HasQueryFilter(s =>
AuchUnveroeffentlichtes
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
b.Entity(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(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().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().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().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().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().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().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().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity(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().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
}
}