Neue Funde erst prüfen, dann veröffentlichen

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

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

Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien.
Schriebe jede ihren eigenen Filter, reichte eine vergessene — und
auffallen würde es nicht beim Programmieren, sondern wenn jemand im
Haushalt einen Titel sieht, den er nicht sehen sollte. Deshalb ein
globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt,
und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen
darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten
ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die
Middleware.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 13:03:24 +02:00
co-authored by Claude Opus 5
parent eb63271e92
commit 632d3efa35
18 changed files with 1726 additions and 11 deletions
+21 -2
View File
@@ -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.
/// </remarks>
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,
/// <summary>Ob neue Funde erst geprüft werden.</summary>
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,
/// <summary>Ob neue Funde erst geprüft werden. Vorgabe: nein.</summary>
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);
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
public sealed record Pruefeintrag(
string Kind, Guid Id, string Title, int? Year, string? PosterImage,
/// <summary><c>null</c> heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant.</summary>
DateTimeOffset? PublishAt,
string? Path,
/// <summary>Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder.</summary>
bool Matched);
public sealed record FreigabeRequest(IReadOnlyList<Guid>? Ids, DateTimeOffset? PublishAt);
/// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes);
@@ -0,0 +1,101 @@
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen.</summary>
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 });
});
}
}
@@ -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