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); }); } }