Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen und Passwort selbst und ist danach angemeldet. Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat, einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es versehentlich zu setzen. **Der Link steht genau einmal da.** Gespeichert ist nur sein Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem Reverse Proxy rät er falsch. **Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide „eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da — zwei gleichzeitige Einlösungen ergeben genau ein Konto. **Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem Einlösen kommt ein Bild mit 200 zurück. **Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie, wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite, ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen Administrator bekommt. Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt: abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der Gastgeber rät mit. Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit `NOCASE`. Das Namensrennen war also nie offen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
465 lines
22 KiB
C#
465 lines
22 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<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<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);
|
|
});
|
|
}
|
|
}
|