Neue Funde erst prüfen, dann veröffentlichen

Je Mediathek einstellbar, Vorgabe aus: wer eine Mediathek anlegt, will sie
sehen. Angeschaltet warten neue Funde unter "Zu prüfen", bis jemand sie
freigibt — oder ihnen einen Termin gibt.

Ein Zeitpunkt, kein Zustandsfeld. PublishAt null heisst wartet, in der
Vergangenheit heisst veröffentlicht, in der Zukunft heisst geplant. Die
Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
umgeschaltet; damit stimmt sie nach einem Neustart, nach einer
Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
nicht lief. Ein Ticker hätte in jedem dieser Fälle das Falsche getan.

Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien.
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. Deshalb ein
globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt,
und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen
darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten
ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die
Middleware.

Der Test zählt die Inhaltsrouten auf und prüft jede einzeln auf die
Abwesenheit eines wartenden Titels. Eine neue Route, die den Filter
umgeht, fällt dort durch.

Die Migration trägt für alle vorhandenen Titel das Hinzufügedatum als
Veröffentlichung nach. Ohne diese zwei Zeilen stünde jeder auf null — und
der gesamte Bestand wäre für alle ausser Administratoren verschwunden,
sofort beim Start im Container und ohne Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 13:03:24 +02:00
co-authored by Claude Opus 5
parent eb63271e92
commit 632d3efa35
18 changed files with 1726 additions and 11 deletions
+49
View File
@@ -22,6 +22,36 @@ namespace Flaro.Data;
/// </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>();
@@ -238,6 +268,25 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
// **Die Freigaberegel, einmal und für jede Abfrage.**
//
// Ein Titel ist sichtbar, wenn ein Zeitpunkt gesetzt ist und er
// erreicht wurde. 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 && m.PublishAt <= Jetzt));
b.Entity<Episode>().HasQueryFilter(f =>
AuchUnveroeffentlichtes || (f.PublishAt != null && f.PublishAt <= Jetzt));
// **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 && f.PublishAt <= Jetzt));
b.Entity<ItemTranslation>(e =>
{
e.HasKey(x => x.Id);
+17
View File
@@ -45,6 +45,8 @@ public sealed class LibraryScanner(
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
var laufZeit = clock.GetUtcNow();
_laufZeit = laufZeit;
_pruefungNoetig = bibliothek.ReviewNewItems;
// **Die Notbremse, und sie steht vor allem anderen.**
//
@@ -308,6 +310,16 @@ public sealed class LibraryScanner(
/// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne.
/// </para>
/// </remarks>
/// <summary>Ob neue Funde dieses Laufs erst geprüft werden sollen.</summary>
///
/// <remarks>
/// Als Feld und nicht als Parameter durch vier Methoden gereicht: es gilt
/// für den ganzen Lauf und ändert sich darin nicht.
/// </remarks>
private bool _pruefungNoetig;
private DateTimeOffset _laufZeit;
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
{
var treffer = bekannt.FirstOrDefault(m =>
@@ -334,6 +346,10 @@ public sealed class LibraryScanner(
Title = name.Title,
SortTitleValue = SortTitle.For(name.Title),
Year = name.Year,
// **Sofort sichtbar, ausser die Mediathek verlangt eine Prüfung.**
// Die Vorgabe ist „erscheinen": wer eine Mediathek anlegt, will
// sie sehen.
PublishAt = _pruefungNoetig ? null : _laufZeit,
};
db.Movies.Add(neu);
bekannt.Add(neu);
@@ -384,6 +400,7 @@ public sealed class LibraryScanner(
SeasonNumber = name.Season,
EpisodeNumber = name.Episode,
Title = name.EpisodeTitle,
PublishAt = _pruefungNoetig ? null : _laufZeit,
};
db.Episodes.Add(neu);
bekannt.Add(neu);
@@ -0,0 +1,882 @@
// <auto-generated />
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("20261004105814_Freigabe")]
partial class Freigabe
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("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.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Freigabe : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Movies",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ReviewNewItems",
table: "Libraries",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Episodes",
type: "INTEGER",
nullable: true);
// **Alles Vorhandene gilt als veröffentlicht.**
//
// Ohne diese zwei Zeilen stünde nach der Migration jeder Titel auf
// `null` — und `null` heisst „wartet auf Prüfung". Der gesamte
// Bestand wäre für alle ausser Administratoren verschwunden, und
// zwar sofort beim Start im Container, ohne Meldung.
//
// Das Hinzufügedatum und nicht `now`: so bleibt „zuletzt
// hinzugefügt" eine sinnvolle Reihenfolge, statt dass vierhundert
// Filme auf dieselbe Sekunde fallen.
migrationBuilder.Sql("UPDATE Movies SET PublishAt = AddedAt WHERE PublishAt IS NULL;");
migrationBuilder.Sql("""
UPDATE Episodes SET PublishAt = (
SELECT COALESCE(MIN(f.SeenAt), '2000-01-01 00:00:00')
FROM MediaFiles f WHERE f.EpisodeId = Episodes.Id)
WHERE PublishAt IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Series");
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Movies");
migrationBuilder.DropColumn(
name: "ReviewNewItems",
table: "Libraries");
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Episodes");
}
}
}
@@ -132,6 +132,9 @@ namespace Flaro.Data.Migrations
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
@@ -190,6 +193,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
@@ -482,6 +488,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
@@ -599,6 +608,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
+50
View File
@@ -0,0 +1,50 @@
using Flaro.Core.Library;
namespace Flaro.Data;
/// <summary>Was ein Benutzer sehen darf — an genau einer Stelle entschieden.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Methode, nicht zwanzig Bedingungen.</b> Auf <c>db.Movies</c> und
/// <c>db.Series</c> wird an rund zwanzig Stellen in vier Dateien zugegriffen —
/// Mediathek, Suche, Vorschläge, Weiterschauen, Zuordnung, Verwaltung. Würde
/// jede ihren eigenen Filter schreiben, reichte eine vergessene, damit
/// unveröffentlichter Inhalt dort trotzdem auftaucht. Und bemerken würde man
/// es nicht beim Programmieren, sondern wenn jemand im Haushalt einen Film
/// sieht, den er nicht sehen sollte.
/// </para>
/// <para>
/// <b>Gerechnet, nicht gespeichert.</b> Sichtbarkeit ist
/// <c>PublishAt != null &amp;&amp; PublishAt &lt;= jetzt</c> — eine Rechnung,
/// kein Zustand. Damit stimmt sie nach einem Neustart, nach einer
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
/// nicht lief.
/// </para>
/// </remarks>
public static class Sichtbarkeit
{
/// <summary>Nur veröffentlichte Filme — Administratoren sehen alles.</summary>
public static IQueryable<Movie> Sichtbar(
this IQueryable<Movie> abfrage, DateTimeOffset jetzt, bool fuerAdmin) =>
fuerAdmin ? abfrage : abfrage.Where(m => m.PublishAt != null && m.PublishAt <= jetzt);
/// <summary>Nur veröffentlichte Folgen.</summary>
public static IQueryable<Episode> Sichtbar(
this IQueryable<Episode> abfrage, DateTimeOffset jetzt, bool fuerAdmin) =>
fuerAdmin ? abfrage : abfrage.Where(f => f.PublishAt != null && f.PublishAt <= jetzt);
/// <summary>Eine Serie, sobald eine ihrer Folgen sichtbar ist.</summary>
///
/// <remarks>
/// <b>Kein eigenes Feld an der Serie.</b> Sonst gäbe es zwei Wahrheiten —
/// eine Serie, die als veröffentlicht gilt, deren Folgen aber alle noch
/// warten, wäre ein leerer Eintrag in der Mediathek.
/// </remarks>
public static IQueryable<Series> Sichtbar(
this IQueryable<Series> abfrage, FlaroContext db, DateTimeOffset jetzt, bool fuerAdmin) =>
fuerAdmin
? abfrage
: abfrage.Where(s => db.Episodes.Any(
f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= jetzt));
}