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;