diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 0f42a61..218fa10 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -55,11 +55,19 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is /// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte /// Bibliothek als leer auszugeben wäre die falsche. /// -public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount); +public sealed record LibraryResponse( + Guid Id, string Name, string Kind, string Path, int? ItemCount, + /// Ob neue Funde erst geprüft werden. + bool ReviewNewItems = false); public sealed record RenameLibraryRequest(string Name); -public sealed record CreateLibraryRequest(string Name, string Kind, string Path); +public sealed record CreateLibraryRequest( + string Name, string Kind, string Path, + /// Ob neue Funde erst geprüft werden. Vorgabe: nein. + bool ReviewNewItems = false); + +public sealed record PruefungRequest(bool ReviewNewItems); public sealed record SprachRequest(string? Language); @@ -76,6 +84,17 @@ public sealed record Bildauswahl( public sealed record BildWahlRequest(string Kind, string? Url); +/// Ein Titel, der noch wartet oder geplant ist. +public sealed record Pruefeintrag( + string Kind, Guid Id, string Title, int? Year, string? PosterImage, + /// null heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. + DateTimeOffset? PublishAt, + string? Path, + /// Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder. + bool Matched); + +public sealed record FreigabeRequest(IReadOnlyList? Ids, DateTimeOffset? PublishAt); + /// Ein Protokolltag. public sealed record Protokolltag(string Date, long Bytes); diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs new file mode 100644 index 0000000..4c6506f --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs @@ -0,0 +1,101 @@ +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Endpoints; + +/// Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen. +public static class FreigabeEndpoints +{ + public static void MapFreigabe(this IEndpointRouteBuilder app) + { + var gruppe = app.MapGroup("/api/v1/review") + .RequireAuthorization(FlaroPolicies.Administrator); + + // **Was noch wartet, und was schon geplant ist.** + // + // Beides in einer Liste: ein geplanter Titel ist nicht erledigt, er + // hat nur schon einen Termin. Ihn verschwinden zu lassen hiesse, dass + // man ihn nur findet, wenn man sich den Termin gemerkt hat. + gruppe.MapGet("/", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var jetzt = uhr.GetUtcNow(); + + var filme = await db.Movies.AsNoTracking() + .Where(m => m.PublishAt == null || m.PublishAt > jetzt) + .OrderBy(m => m.SortTitleValue) + .Select(m => new Pruefeintrag( + "movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt, + db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(), + m.TmdbId != null)) + .ToListAsync(ct); + + var folgen = await db.Episodes.AsNoTracking() + .Where(f => f.PublishAt == null || f.PublishAt > jetzt) + .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) + .Select(f => new Pruefeintrag( + "episode", f.Id, + db.Series.Where(s => s.Id == f.SeriesId).Select(s => s.Title).First() + + $" · S{f.SeasonNumber}E{f.EpisodeNumber}", + null, + db.Series.Where(s => s.Id == f.SeriesId).Select(s => s.PosterImage).First(), + f.PublishAt, + db.MediaFiles.Where(d => d.EpisodeId == f.Id).Select(d => d.Path).FirstOrDefault(), + true)) + .ToListAsync(ct); + + return Results.Ok(filme.Concat(folgen).ToList()); + }); + + // Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt. + gruppe.MapPost("/publish", async ( + FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var wann = anfrage.PublishAt ?? uhr.GetUtcNow(); + + // **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei + // vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung. + if (anfrage.Ids is null or { Count: 0 }) + { + // **Alles, was noch nicht sichtbar ist** — Wartendes und + // Geplantes. Nur das Wartende zu fassen hiesse: ein auf + // nächste Woche gelegter Titel bliebe liegen, obwohl er in + // derselben Liste stand und „Alle veröffentlichen" gedrückt + // wurde. + var jetzt = uhr.GetUtcNow(); + + var alleFilme = await db.Movies + .Where(m => m.PublishAt == null || m.PublishAt > jetzt) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct); + var alleFolgen = await db.Episodes + .Where(f => f.PublishAt == null || f.PublishAt > jetzt) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct); + + return Results.Ok(new { published = alleFilme + alleFolgen }); + } + + var ids = anfrage.Ids; + var filme = await db.Movies.Where(m => ids.Contains(m.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct); + var folgen = await db.Episodes.Where(f => ids.Contains(f.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct); + + return Results.Ok(new { published = filme + folgen }); + }); + + // Zurückziehen — wieder in die Prüfung. + gruppe.MapPost("/withhold", async ( + FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) => + { + if (anfrage.Ids is null or { Count: 0 }) + return Results.BadRequest(new { fehler = "Ohne Kennungen gibt es nichts zurückzuziehen." }); + + var ids = anfrage.Ids; + var filme = await db.Movies.Where(m => ids.Contains(m.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, (DateTimeOffset?)null), ct); + var folgen = await db.Episodes.Where(f => ids.Contains(f.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, (DateTimeOffset?)null), ct); + + return Results.Ok(new { withheld = filme + folgen }); + }); + } +} diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index a83c6dd..2d5527c 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -69,7 +69,8 @@ public static class ManagementEndpoints l.Id, l.Name, l.Kind.ToString(), l.Path, l.Kind == LibraryKind.Shows ? serien.GetValueOrDefault(l.Id) - : filme.GetValueOrDefault(l.Id))).ToList()); + : filme.GetValueOrDefault(l.Id), + l.ReviewNewItems)).ToList()); }).RequireAuthorization(); // **Was nicht mehr da ist, aber noch in der Mediathek steht.** @@ -77,6 +78,15 @@ public static class ManagementEndpoints // Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte // Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen // steht es hier, und das Löschen ist eine Entscheidung. + // Die Prüfung je Mediathek umstellen. + bibliotheken.MapPut("/{id:guid}/review", async ( + Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) => + { + var betroffen = await db.Libraries.Where(l => l.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ReviewNewItems, anfrage.ReviewNewItems), ct); + return betroffen > 0 ? Results.NoContent() : Results.NotFound(); + }).RequireAuthorization(FlaroPolicies.Administrator); + bibliotheken.MapGet("/missing", async (FlaroContext db) => { var liste = await db.MediaFiles.AsNoTracking() @@ -141,11 +151,16 @@ public static class ManagementEndpoints "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."], }); - var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path }; + var neu = new Library + { + Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path, + ReviewNewItems = anfrage.ReviewNewItems, + }; db.Libraries.Add(neu); await db.SaveChangesAsync(); return Results.Created($"/api/v1/libraries/{neu.Id}", - new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0)); + new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0, + neu.ReviewNewItems)); }).RequireAuthorization(FlaroPolicies.Administrator); // **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in diff --git a/server/src/Flaro.Api/Freigabefilter.cs b/server/src/Flaro.Api/Freigabefilter.cs new file mode 100644 index 0000000..9a0e3e6 --- /dev/null +++ b/server/src/Flaro.Api/Freigabefilter.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; +using Flaro.Api.Auth; +using Flaro.Data; + +namespace Flaro.Api; + +/// Setzt je Anfrage, wer Unveröffentlichtes sehen darf. +/// +/// +/// +/// Als Middleware und nicht in den Endpunkten. Zwanzig Endpunkte +/// müssten daran denken; diese eine Stelle läuft vor allen. Was sie setzt, +/// greift über den globalen Abfragefilter in auf +/// jede Abfrage — auch auf die, die erst nächstes Jahr geschrieben wird. +/// +/// +/// Nach der Authentifizierung, vor den Endpunkten. Vorher stünde im +/// ClaimsPrincipal noch nichts, und jeder gälte als gewöhnlicher +/// Benutzer — die Verwaltung bekäme ihre eigene Prüfliste nicht zu sehen. +/// +/// +public static class Freigabefilter +{ + public static IApplicationBuilder UseFreigabefilter(this IApplicationBuilder app) => + app.Use(async (zusammenhang, weiter) => + { + var db = zusammenhang.RequestServices.GetService(); + if (db is not null) + { + db.Jetzt = zusammenhang.RequestServices + .GetRequiredService().GetUtcNow(); + db.AuchUnveroeffentlichtes = + zusammenhang.User.IsInRole(FlaroRoles.Administrator); + } + + await weiter(); + }); +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index a44b6c8..94b191b 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -176,6 +176,10 @@ if (Directory.Exists(oberfläche)) app.UseAuthentication(); app.UseAuthorization(); +// **Nach der Anmeldung, vor den Endpunkten.** Davor stünde im Principal noch +// nichts, und ein Administrator sähe seine eigene Prüfliste nicht. +app.UseFreigabefilter(); + app.MapSystem(); app.MapAuth(); app.MapManagement(); @@ -186,6 +190,7 @@ app.MapDelivery(); app.MapPlayback(); app.MapMatching(); app.MapArtwork(); +app.MapFreigabe(); // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die diff --git a/server/src/Flaro.Core/Libraries/Library.cs b/server/src/Flaro.Core/Libraries/Library.cs index fc249e0..0dc5dc4 100644 --- a/server/src/Flaro.Core/Libraries/Library.cs +++ b/server/src/Flaro.Core/Libraries/Library.cs @@ -17,4 +17,14 @@ public sealed class Library public required LibraryKind Kind { get; set; } public required string Path { get; set; } public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// Ob neue Funde erst geprüft werden, bevor sie erscheinen. + /// + /// + /// Vorgabe: aus. Wer eine Mediathek anlegt, will sie sehen. Die + /// Prüfung ist für den Fall gedacht, dass Zuordnung und Bilder stimmen + /// sollen, bevor jemand im Haushalt den Film findet — oder dass etwas + /// erst zu einem bestimmten Zeitpunkt erscheinen soll. + /// + public bool ReviewNewItems { get; set; } } diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index e149532..375f371 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -110,6 +110,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert /// Ohne führenden Artikel — siehe . public required string SortTitleValue { get; set; } + /// Ab wann dieser Titel für alle sichtbar ist. + /// + /// + /// + /// null heisst: wartet auf Prüfung. Ein Zeitpunkt in der + /// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant. + /// + /// + /// Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern. 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 oder gar nichts. + /// + /// + public DateTimeOffset? PublishAt { get; set; } + /// Von Hand gewählt — ein Auffrischen überschreibt es nicht. /// /// @@ -229,6 +247,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert public required string Title { get; set; } public required string SortTitleValue { get; set; } + /// Ab wann dieser Titel für alle sichtbar ist. + /// + /// + /// + /// null heisst: wartet auf Prüfung. Ein Zeitpunkt in der + /// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant. + /// + /// + /// Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern. 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 oder gar nichts. + /// + /// + public DateTimeOffset? PublishAt { get; set; } + /// Von Hand gewählt — ein Auffrischen überschreibt es nicht. /// /// @@ -326,5 +362,15 @@ public sealed class Episode /// Das Standbild der Folge. public string? StillImage { get; set; } + /// Ab wann diese Folge für alle sichtbar ist — siehe . + /// + /// + /// An der Folge, nicht an der Serie. Eine neue Staffel einer + /// laufenden Serie soll sich einzeln planen lassen; eine Serie ist + /// sichtbar, sobald eine ihrer Folgen es ist, und braucht damit kein + /// eigenes Feld. + /// + public DateTimeOffset? PublishAt { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 95795f7..4a374e3 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -22,6 +22,36 @@ namespace Flaro.Data; /// 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(); @@ -238,6 +268,25 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.HasOne().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().HasQueryFilter(m => + AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt)); + + b.Entity().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().HasQueryFilter(s => + AuchUnveroeffentlichtes + || Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= Jetzt)); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index 5238a9b..df173ae 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -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. /// /// + /// Ob neue Funde dieses Laufs erst geprüft werden sollen. + /// + /// + /// Als Feld und nicht als Parameter durch vier Methoden gereicht: es gilt + /// für den ganzen Lauf und ändert sich darin nicht. + /// + private bool _pruefungNoetig; + + private DateTimeOffset _laufZeit; + private Movie FilmFinden(List 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); diff --git a/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.Designer.cs b/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.Designer.cs new file mode 100644 index 0000000..cfe2169 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.Designer.cs @@ -0,0 +1,882 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.cs b/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.cs new file mode 100644 index 0000000..65f6202 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004105814_Freigabe.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Freigabe : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PublishAt", + table: "Series", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PublishAt", + table: "Movies", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReviewNewItems", + table: "Libraries", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + 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; + """); + + } + + /// + 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"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 5f73605..c596ebe 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -132,6 +132,9 @@ namespace Flaro.Data.Migrations .HasMaxLength(1024) .HasColumnType("TEXT"); + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.ToTable("Libraries"); @@ -190,6 +193,9 @@ namespace Flaro.Data.Migrations b.Property("Overview") .HasColumnType("TEXT"); + b.Property("PublishAt") + .HasColumnType("INTEGER"); + b.Property("SeasonNumber") .HasColumnType("INTEGER"); @@ -482,6 +488,9 @@ namespace Flaro.Data.Migrations b.Property("PosterPinned") .HasColumnType("INTEGER"); + b.Property("PublishAt") + .HasColumnType("INTEGER"); + b.Property("Rating") .HasColumnType("REAL"); @@ -599,6 +608,9 @@ namespace Flaro.Data.Migrations b.Property("PosterPinned") .HasColumnType("INTEGER"); + b.Property("PublishAt") + .HasColumnType("INTEGER"); + b.Property("Rating") .HasColumnType("REAL"); diff --git a/server/src/Flaro.Data/Sichtbarkeit.cs b/server/src/Flaro.Data/Sichtbarkeit.cs new file mode 100644 index 0000000..535f775 --- /dev/null +++ b/server/src/Flaro.Data/Sichtbarkeit.cs @@ -0,0 +1,50 @@ +using Flaro.Core.Library; + +namespace Flaro.Data; + +/// Was ein Benutzer sehen darf — an genau einer Stelle entschieden. +/// +/// +/// +/// Eine Methode, nicht zwanzig Bedingungen. Auf db.Movies und +/// db.Series 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. +/// +/// +/// Gerechnet, nicht gespeichert. Sichtbarkeit ist +/// PublishAt != null && PublishAt <= jetzt — 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. +/// +/// +public static class Sichtbarkeit +{ + /// Nur veröffentlichte Filme — Administratoren sehen alles. + public static IQueryable Sichtbar( + this IQueryable abfrage, DateTimeOffset jetzt, bool fuerAdmin) => + fuerAdmin ? abfrage : abfrage.Where(m => m.PublishAt != null && m.PublishAt <= jetzt); + + /// Nur veröffentlichte Folgen. + public static IQueryable Sichtbar( + this IQueryable abfrage, DateTimeOffset jetzt, bool fuerAdmin) => + fuerAdmin ? abfrage : abfrage.Where(f => f.PublishAt != null && f.PublishAt <= jetzt); + + /// Eine Serie, sobald eine ihrer Folgen sichtbar ist. + /// + /// + /// Kein eigenes Feld an der Serie. 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. + /// + public static IQueryable Sichtbar( + this IQueryable 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)); +} diff --git a/server/tests/Flaro.Api.Tests/FlaroServer.cs b/server/tests/Flaro.Api.Tests/FlaroServer.cs index dad5b54..84dcc05 100644 --- a/server/tests/Flaro.Api.Tests/FlaroServer.cs +++ b/server/tests/Flaro.Api.Tests/FlaroServer.cs @@ -87,6 +87,23 @@ public sealed class FlaroServer : IDisposable return (await antwort.Content.ReadFromJsonAsync())!; } + /// Legt einen gewöhnlichen Benutzer an und meldet ihn an. + /// + /// + /// Für alles, was aus der Sicht eines Nicht-Administrators geprüft werden + /// muss — und das ist mehr, als es klingt: der Unterschied zwischen + /// „sieht es" und „sieht es nicht" ist bei der Freigabe der ganze Zweck. + /// + public async Task NeuerBenutzer(HttpClient alsAdmin, string name, string passwort) + { + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest(name, passwort, false)); + angelegt.EnsureSuccessStatusCode(); + + var anmeldung = await SignIn(name, passwort, "Prüfgerät", "pruefgeraet-" + name); + return AlsAngemeldeter(anmeldung.Token); + } + /// Ein eigener Client, der ein Token vorzeigt. public HttpClient AlsAngemeldeter(string token) { diff --git a/server/tests/Flaro.Api.Tests/FreigabeTests.cs b/server/tests/Flaro.Api.Tests/FreigabeTests.cs new file mode 100644 index 0000000..ee0c87a --- /dev/null +++ b/server/tests/Flaro.Api.Tests/FreigabeTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Was auf Prüfung wartet, sieht niemand ausser Administratoren. +/// +/// +/// +/// Die Route-Aufzählung unten ist der eigentliche Test. Auf Filme und +/// Folgen wird an rund zwanzig Stellen zugegriffen — Mediathek, Suche, +/// Vorschläge, Weiterschauen, Ähnliches. Eine davon ohne Filter, und +/// unveröffentlichter Inhalt erscheint trotzdem; auffallen würde es nicht +/// beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht, +/// den er nicht sehen sollte. +/// +/// +/// Deshalb prüft dieser Test nicht eine Abfrage, sondern jede Route, die +/// Inhalt ausliefert — und zwar auf die Abwesenheit eines Titels. Eine +/// neue Route, die den Filter umgeht, fällt hier durch. +/// +/// +public class FreigabeTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-freigabe-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + + /// Jede Route, die Titel ausliefert. + private static readonly string[] Inhaltsrouten = + [ + "/api/v1/movies", + "/api/v1/movies?search=Geheim", + "/api/v1/movies?sort=added", + "/api/v1/series", + "/api/v1/home/continue", + "/api/v1/home/next", + "/api/v1/home/suggestions", + ]; + + [Test] + public async Task Ungepruefte_Titel_sieht_nur_der_Administrator() + { + Datei("Geheimsache (2024).mkv"); + + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand, ReviewNewItems: true)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + await Scannen(alsAdmin, bibliothek.Id); + + // Der Administrator sieht ihn — sonst könnte er ihn nicht prüfen. + var wartend = await alsAdmin.GetFromJsonAsync>("/api/v1/review"); + Assert.That(wartend, Has.Count.EqualTo(1)); + Assert.That(wartend![0].Title, Is.EqualTo("Geheimsache")); + + // Ein gewöhnlicher Benutzer nirgends. + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswort"); + + foreach (var route in Inhaltsrouten) + { + var antwort = await gast.GetAsync(route); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK), route); + var text = await antwort.Content.ReadAsStringAsync(); + Assert.That(text, Does.Not.Contain("Geheimsache"), + $"„Geheimsache“ steht in {route} — dort fehlt der Freigabefilter."); + } + } + + [Test] + public async Task Veroeffentlichen_macht_ihn_fuer_alle_sichtbar() + { + Datei("Geheimsache (2024).mkv"); + + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand, ReviewNewItems: true)); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + await Scannen(alsAdmin, bibliothek.Id); + + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswort"); + Assert.That(await Titel(gast), Is.Empty); + + var frei = await alsAdmin.PostAsJsonAsync("/api/v1/review/publish", + new FreigabeRequest(null, null)); + frei.EnsureSuccessStatusCode(); + + Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" })); + } + + /// Ein Zeitpunkt in der Zukunft hält ihn zurück — ohne Hintergrunddienst. + [Test] + public async Task Ein_geplanter_Titel_erscheint_erst_zu_seiner_Zeit() + { + Datei("Geheimsache (2024).mkv"); + + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand, ReviewNewItems: true)); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + await Scannen(alsAdmin, bibliothek.Id); + + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswort"); + + // Auf morgen gelegt. + await alsAdmin.PostAsJsonAsync("/api/v1/review/publish", + new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(1))); + Assert.That(await Titel(gast), Is.Empty, "Ein geplanter Titel ist noch nicht sichtbar."); + + // Und er steht weiterhin in der Prüfliste — mit seinem Termin. + var wartend = await alsAdmin.GetFromJsonAsync>("/api/v1/review"); + Assert.That(wartend, Has.Count.EqualTo(1)); + Assert.That(wartend![0].PublishAt, Is.Not.Null); + + // Auf gestern gelegt: sofort da, ohne dass irgendetwas umgeschaltet wurde. + await alsAdmin.PostAsJsonAsync("/api/v1/review/publish", + new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1))); + Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" })); + } + + /// Ohne Prüfung erscheint alles sofort — das ist die Vorgabe. + [Test] + public async Task Ohne_Pruefung_erscheint_alles_sofort() + { + Datei("Offen (2024).mkv"); + + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand)); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + await Scannen(alsAdmin, bibliothek.Id); + + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswort"); + Assert.That(await Titel(gast), Is.EqualTo(new[] { "Offen" })); + Assert.That(await alsAdmin.GetFromJsonAsync>("/api/v1/review"), Is.Empty); + } + + private static async Task Titel(HttpClient client) => + (await client.GetFromJsonAsync>("/api/v1/movies"))! + .Items.Select(m => m.Title).ToArray(); + + private static async Task Scannen(HttpClient client, Guid bibliothek) + { + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)) + .EnsureSuccessStatusCode(); + + for (var versuch = 0; versuch < 600; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State is "finished") return; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + + Assert.Fail("Der Scan wurde nicht fertig."); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index e41daa8..b8ec504 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -83,6 +83,9 @@ export type Bibliothek = { path: string; /** `null` heisst „nicht gezählt", nicht „leer". */ itemCount: number | null; + + /** Ob neue Funde erst geprüft werden. */ + reviewNewItems: boolean; }; export type Seite = { items: T[]; total: number }; export type Lieferung = { @@ -516,6 +519,31 @@ export const bildHochladen = async ( { method: 'POST', body: inhalt }); }; +export type Pruefeintrag = { + kind: 'movie' | 'episode'; + id: string; title: string; year: number | null; + posterImage: string | null; + /** `null` heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. */ + publishAt: string | null; + path: string | null; + matched: boolean; +}; + +export const zuPruefen = () => anfrage('/review'); + +/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */ +export const veroeffentlichen = (ids?: string[], publishAt?: string) => + anfrage<{ published: number }>('/review/publish', + { method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) }); + +export const zurueckziehen = (ids: string[]) => + anfrage<{ withheld: number }>('/review/withhold', + { method: 'POST', body: JSON.stringify({ ids, publishAt: null }) }); + +export const pruefungSetzen = (bibliothek: string, an: boolean) => + anfrage(`/libraries/${bibliothek}/review`, + { method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) }); + export type Protokolltag = { date: string; bytes: number }; /** Welche Tage protokolliert sind, neueste zuerst. */ @@ -560,9 +588,12 @@ export const bibliothekUmbenennen = (id: string, name: string) => export const bibliothekLoeschen = (id: string) => anfrage(`/libraries/${id}`, { method: 'DELETE' }); -export const bibliothekAnlegen = (name: string, art: string, pfad: string) => +export const bibliothekAnlegen = ( + name: string, art: string, pfad: string, pruefen = false, +) => anfrage('/libraries', { - method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }), + method: 'POST', + body: JSON.stringify({ name, kind: art, path: pfad, reviewNewItems: pruefen }), }); export const scanStarten = (id: string) => diff --git a/web/app/src/style.css b/web/app/src/style.css index 7c24c7b..0ee6a27 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -929,3 +929,12 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m background: var(--accent); color: #fff; } .bildwahl-marke svg { width: 14px; height: 14px; } + +/* Prüfliste — mit Miniplakat, damit erkennbar ist, worum es geht. */ +.pruefzeile .pruef-bild { + width: 40px; height: 60px; flex: none; + border-radius: 4px; overflow: hidden; + background: var(--placeholder); +} +.pruefzeile .pruef-bild img { width: 100%; height: 100%; object-fit: cover; display: block; } +.pruefzeile .knopfreihe { align-items: center; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 268057d..857b1f4 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -3,7 +3,8 @@ import { scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, vermisste, vermisstesEntfernen, type VermissteDatei, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, - meineSprache, + meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, + type Pruefeintrag, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, @@ -27,7 +28,7 @@ export function verwaltung( signal: AbortSignal = new AbortController().signal, ): HTMLElement { const wurzel = document.createElement('div'); - wurzel.append(dashboard(signal), vermisstenbereich(geaendert), + wurzel.append(dashboard(signal), pruefbereich(geaendert), vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert), protokollbereich()); return wurzel; @@ -161,6 +162,134 @@ function laufzeit(sekunden: number): string { return `${minuten} Min.`; } +/* ---------- Zu prüfen ---------- */ + +/** Neue Funde, die noch nicht erscheinen — und geplante mit ihrem Termin. + * + * **Erscheint nur, wenn etwas wartet.** Ein leerer Abschnitt wäre Rauschen. + * + * Geplante bleiben in der Liste. Ein Titel mit Termin ist nicht erledigt, er + * hat nur schon einen — ihn verschwinden zu lassen hiesse, dass man ihn nur + * wiederfindet, wenn man sich den Termin gemerkt hat. */ +function pruefbereich(geaendert: () => void): HTMLElement { + const bereich = document.createElement('section'); + bereich.className = 'hidden'; + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + + const alle = knopf('Alle veröffentlichen', 'btn btn-primary'); + alle.onclick = async () => { + alle.disabled = true; + try { await veroeffentlichen(); await laden(); geaendert(); } + catch (fehler) { liste.prepend(fehlerzeile((fehler as Error).message)); alle.disabled = false; } + }; + + bereich.append(abschnitt('Zu prüfen', [alle]), liste); + void laden(); + + async function laden(): Promise { + let wartend: Pruefeintrag[]; + try { wartend = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; } + + if (wartend.length === 0) { bereich.classList.add('hidden'); return; } + bereich.classList.remove('hidden'); + alle.disabled = false; + + liste.innerHTML = ''; + wartend.forEach(e => liste.appendChild(zeileFuer(e))); + } + + function zeileFuer(e: Pruefeintrag): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung pruefzeile'; + + const bild = document.createElement('div'); + bild.className = 'pruef-bild'; + const adresse = bildAdresse(e.posterImage); + if (adresse) { + const img = document.createElement('img'); + img.src = adresse; img.alt = ''; img.loading = 'lazy'; + bild.appendChild(img); + } + + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + const geplant = e.publishAt ? new Date(e.publishAt) : null; + const stand = geplant + ? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}` + : e.matched ? 'Wartet auf Freigabe' + // Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe + // das Nächste, sondern die Zuordnung. + : 'Wartet — und ist noch keinem Titel zugeordnet'; + text.innerHTML = `${esc(e.title)}${e.year ? ` (${e.year})` : ''}` + + `${esc(stand)}`; + + const frei = knopf('Veröffentlichen', 'btn'); + frei.onclick = async () => { + frei.disabled = true; + try { await veroeffentlichen([e.id]); await laden(); geaendert(); } + catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); frei.disabled = false; } + }; + + const planen = knopf('Planen', 'btn'); + planen.onclick = () => dialog({ + titel: `„${e.title}" planen`, + text: 'Ab diesem Zeitpunkt erscheint der Titel für alle. ' + + 'Es läuft dafür nichts im Hintergrund — die Sichtbarkeit wird bei ' + + 'jeder Abfrage gerechnet und stimmt deshalb auch, wenn der Server ' + + 'gerade nicht lief.', + felder: [{ + name: 'wann', beschriftung: 'Zeitpunkt', art: 'text', + wert: inEingabeform(geplant ?? new Date(Date.now() + 86400000)), + hinweis: 'Form: 2026-12-24 20:15', + }], + bestaetigen: 'Planen', + tun: async wert => { + const wann = new Date(wert('wann').replace(' ', 'T')); + if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.'); + await veroeffentlichen([e.id], wann.toISOString()); + await laden(); + }, + }); + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + oben.append(bild, text, frei, planen); + + if (geplant) { + const zurueck = knopf('Zurückziehen', 'btn'); + zurueck.onclick = async () => { + zurueck.disabled = true; + try { await zurueckziehen([e.id]); await laden(); } + catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; } + }; + oben.appendChild(zurueck); + } + + d.appendChild(oben); + + if (e.path) { + const pfad = document.createElement('div'); + pfad.className = 'winzig'; + pfad.textContent = e.path; + d.appendChild(pfad); + } + + return d; + } + + /** „2026-12-24 20:15" — ohne Zeitzone, weil man sie eintippt, nicht liest. */ + function inEingabeform(d: Date): string { + const z = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ` + + `${z(d.getHours())}:${z(d.getMinutes())}`; + } + + return bereich; +} + /* ---------- Protokolle ---------- */ /** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS. @@ -343,10 +472,17 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle hinweis: 'Der Pfad im Container, nicht der auf dem NAS — ' + 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.', }, + { + name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true, + hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie ' + + 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung ' + + 'und die Bilder zu kontrollieren oder einen Termin zu setzen.', + }, ], bestaetigen: 'Anlegen', tun: async wert => { - await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad')); + await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'), + wert('pruefen') === 'true'); await laden(); geaendert(); }, @@ -471,9 +607,23 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle } } + // Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen + // entschieden wurde, soll kein Urteil fürs Leben sein. + const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn'); + pruefung.onclick = async () => { + pruefung.disabled = true; + try { + await pruefungSetzen(b.id, !b.reviewNewItems); + await laden(); + } catch (fehler) { + d.appendChild(fehlerzeile((fehler as Error).message)); + pruefung.disabled = false; + } + }; + const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; - knoepfe.append(los, auffrischen, umbenennen, entfernen); + knoepfe.append(los, auffrischen, pruefung, umbenennen, entfernen); d.append(knoepfe, stand); void verfolgen(false); return d;