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:
co-authored by
Claude Opus 5
parent
eb63271e92
commit
632d3efa35
@@ -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
|
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||||
/// Bibliothek als leer auszugeben wäre die falsche.
|
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||||
/// </remarks>
|
/// </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 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);
|
public sealed record SprachRequest(string? Language);
|
||||||
|
|
||||||
@@ -76,6 +84,17 @@ public sealed record Bildauswahl(
|
|||||||
|
|
||||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
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>
|
/// <summary>Ein Protokolltag.</summary>
|
||||||
public sealed record Protokolltag(string Date, long Bytes);
|
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.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||||
l.Kind == LibraryKind.Shows
|
l.Kind == LibraryKind.Shows
|
||||||
? serien.GetValueOrDefault(l.Id)
|
? serien.GetValueOrDefault(l.Id)
|
||||||
: filme.GetValueOrDefault(l.Id))).ToList());
|
: filme.GetValueOrDefault(l.Id),
|
||||||
|
l.ReviewNewItems)).ToList());
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
|
// **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
|
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
|
||||||
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
||||||
// steht es hier, und das Löschen ist eine Entscheidung.
|
// 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) =>
|
bibliotheken.MapGet("/missing", async (FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var liste = await db.MediaFiles.AsNoTracking()
|
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."],
|
"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);
|
db.Libraries.Add(neu);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
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);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Data;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Setzt je Anfrage, wer Unveröffentlichtes sehen darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Als Middleware und nicht in den Endpunkten.</b> Zwanzig Endpunkte
|
||||||
|
/// müssten daran denken; diese eine Stelle läuft vor allen. Was sie setzt,
|
||||||
|
/// greift über den globalen Abfragefilter in <see cref="FlaroContext"/> auf
|
||||||
|
/// jede Abfrage — auch auf die, die erst nächstes Jahr geschrieben wird.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nach der Authentifizierung, vor den Endpunkten.</b> Vorher stünde im
|
||||||
|
/// <c>ClaimsPrincipal</c> noch nichts, und jeder gälte als gewöhnlicher
|
||||||
|
/// Benutzer — die Verwaltung bekäme ihre eigene Prüfliste nicht zu sehen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Freigabefilter
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseFreigabefilter(this IApplicationBuilder app) =>
|
||||||
|
app.Use(async (zusammenhang, weiter) =>
|
||||||
|
{
|
||||||
|
var db = zusammenhang.RequestServices.GetService<FlaroContext>();
|
||||||
|
if (db is not null)
|
||||||
|
{
|
||||||
|
db.Jetzt = zusammenhang.RequestServices
|
||||||
|
.GetRequiredService<TimeProvider>().GetUtcNow();
|
||||||
|
db.AuchUnveroeffentlichtes =
|
||||||
|
zusammenhang.User.IsInRole(FlaroRoles.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
await weiter();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -176,6 +176,10 @@ if (Directory.Exists(oberfläche))
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
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.MapSystem();
|
||||||
app.MapAuth();
|
app.MapAuth();
|
||||||
app.MapManagement();
|
app.MapManagement();
|
||||||
@@ -186,6 +190,7 @@ app.MapDelivery();
|
|||||||
app.MapPlayback();
|
app.MapPlayback();
|
||||||
app.MapMatching();
|
app.MapMatching();
|
||||||
app.MapArtwork();
|
app.MapArtwork();
|
||||||
|
app.MapFreigabe();
|
||||||
|
|
||||||
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
// **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
|
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ public sealed class Library
|
|||||||
public required LibraryKind Kind { get; set; }
|
public required LibraryKind Kind { get; set; }
|
||||||
public required string Path { get; set; }
|
public required string Path { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Ob neue Funde erst geprüft werden, bevor sie erscheinen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Vorgabe: aus.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public bool ReviewNewItems { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
|||||||
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
||||||
public required string SortTitleValue { get; set; }
|
public required string SortTitleValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
|
||||||
|
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -229,6 +247,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
|
|||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public required string SortTitleValue { get; set; }
|
public required string SortTitleValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
|
||||||
|
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -326,5 +362,15 @@ public sealed class Episode
|
|||||||
/// <summary>Das Standbild der Folge.</summary>
|
/// <summary>Das Standbild der Folge.</summary>
|
||||||
public string? StillImage { get; set; }
|
public string? StillImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann diese Folge für alle sichtbar ist — siehe <see cref="Movie.PublishAt"/>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>An der Folge, nicht an der Serie.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,36 @@ namespace Flaro.Data;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
|
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
|
/// <summary>Ob diese Anfrage auch Unveröffentlichtes sehen darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Hier und nicht in zwanzig Abfragen.</b> Auf Filme und Folgen wird an
|
||||||
|
/// rund zwanzig Stellen in vier Dateien zugegriffen. Schriebe jede ihren
|
||||||
|
/// eigenen Filter, reichte eine vergessene — und auffallen würde es nicht
|
||||||
|
/// beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht,
|
||||||
|
/// den er nicht sehen sollte. Ein globaler Abfragefilter lässt sich nicht
|
||||||
|
/// vergessen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vorgabe <c>true</c>, gesetzt wird je Anfrage.</b> Der Scanner und
|
||||||
|
/// die Metadaten laufen ohne Benutzer und müssen alles sehen — sonst legt
|
||||||
|
/// der nächste Lauf einen wartenden Titel ein zweites Mal an. Für jede
|
||||||
|
/// HTTP-Anfrage setzt die Middleware den Wert, bevor irgendein Endpunkt
|
||||||
|
/// läuft; dass sie das tut, hält ein Test fest.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool AuchUnveroeffentlichtes { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Der Zeitpunkt, gegen den geplante Titel geprüft werden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Am Kontext und nicht als <c>DateTimeOffset.UtcNow</c> im Filter: ein
|
||||||
|
/// Aufruf mitten im Ausdruck liesse sich nicht nach SQL übersetzen, und
|
||||||
|
/// in Tests wäre die Zeit nicht zu stellen.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset Jetzt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||||
public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
|
public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
|
||||||
@@ -238,6 +268,25 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **Die Freigaberegel, einmal und für jede Abfrage.**
|
||||||
|
//
|
||||||
|
// Ein Titel ist sichtbar, wenn ein Zeitpunkt gesetzt ist und er
|
||||||
|
// erreicht wurde. Gerechnet statt gespeichert: damit stimmt es nach
|
||||||
|
// einem Neustart, nach einer Zeitumstellung und auch dann, wenn der
|
||||||
|
// Server zum geplanten Zeitpunkt gar nicht lief.
|
||||||
|
b.Entity<Movie>().HasQueryFilter(m =>
|
||||||
|
AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt));
|
||||||
|
|
||||||
|
b.Entity<Episode>().HasQueryFilter(f =>
|
||||||
|
AuchUnveroeffentlichtes || (f.PublishAt != null && f.PublishAt <= Jetzt));
|
||||||
|
|
||||||
|
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
|
||||||
|
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
|
||||||
|
// warten, wäre ein leerer Eintrag in der Mediathek.
|
||||||
|
b.Entity<Series>().HasQueryFilter(s =>
|
||||||
|
AuchUnveroeffentlichtes
|
||||||
|
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= Jetzt));
|
||||||
|
|
||||||
b.Entity<ItemTranslation>(e =>
|
b.Entity<ItemTranslation>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public sealed class LibraryScanner(
|
|||||||
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
|
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
|
||||||
|
|
||||||
var laufZeit = clock.GetUtcNow();
|
var laufZeit = clock.GetUtcNow();
|
||||||
|
_laufZeit = laufZeit;
|
||||||
|
_pruefungNoetig = bibliothek.ReviewNewItems;
|
||||||
|
|
||||||
// **Die Notbremse, und sie steht vor allem anderen.**
|
// **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.
|
/// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Ob neue Funde dieses Laufs erst geprüft werden sollen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Als Feld und nicht als Parameter durch vier Methoden gereicht: es gilt
|
||||||
|
/// für den ganzen Lauf und ändert sich darin nicht.
|
||||||
|
/// </remarks>
|
||||||
|
private bool _pruefungNoetig;
|
||||||
|
|
||||||
|
private DateTimeOffset _laufZeit;
|
||||||
|
|
||||||
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
|
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
|
||||||
{
|
{
|
||||||
var treffer = bekannt.FirstOrDefault(m =>
|
var treffer = bekannt.FirstOrDefault(m =>
|
||||||
@@ -334,6 +346,10 @@ public sealed class LibraryScanner(
|
|||||||
Title = name.Title,
|
Title = name.Title,
|
||||||
SortTitleValue = SortTitle.For(name.Title),
|
SortTitleValue = SortTitle.For(name.Title),
|
||||||
Year = name.Year,
|
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);
|
db.Movies.Add(neu);
|
||||||
bekannt.Add(neu);
|
bekannt.Add(neu);
|
||||||
@@ -384,6 +400,7 @@ public sealed class LibraryScanner(
|
|||||||
SeasonNumber = name.Season,
|
SeasonNumber = name.Season,
|
||||||
EpisodeNumber = name.Episode,
|
EpisodeNumber = name.Episode,
|
||||||
Title = name.EpisodeTitle,
|
Title = name.EpisodeTitle,
|
||||||
|
PublishAt = _pruefungNoetig ? null : _laufZeit,
|
||||||
};
|
};
|
||||||
db.Episodes.Add(neu);
|
db.Episodes.Add(neu);
|
||||||
bekannt.Add(neu);
|
bekannt.Add(neu);
|
||||||
|
|||||||
@@ -0,0 +1,882 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(FlaroContext))]
|
||||||
|
[Migration("20261004105814_Freigabe")]
|
||||||
|
partial class Freigabe
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ConsumedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("HandleFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code");
|
||||||
|
|
||||||
|
b.HasIndex("HandleFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AuthCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Libraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Character")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EpisodeNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SeasonNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StillImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Episodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Genres");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GenreId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("GenreLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("BitRate")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Container")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("Duration")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ProbedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "Path")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Channels")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Codec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsForced")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaFileId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaFileId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaStreams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Movies");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Image")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TmdbId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Dismissed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Favourite")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("InProgress")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("Runtime")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Watched")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "InProgress", "UpdatedAt");
|
||||||
|
|
||||||
|
b.ToTable("UserItemStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdministrator")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Person", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Genre", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GenreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.MediaFile", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MediaFileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EpisodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Freigabe : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ReviewNewItems",
|
||||||
|
table: "Libraries",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Episodes",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
// **Alles Vorhandene gilt als veröffentlicht.**
|
||||||
|
//
|
||||||
|
// Ohne diese zwei Zeilen stünde nach der Migration jeder Titel auf
|
||||||
|
// `null` — und `null` heisst „wartet auf Prüfung". Der gesamte
|
||||||
|
// Bestand wäre für alle ausser Administratoren verschwunden, und
|
||||||
|
// zwar sofort beim Start im Container, ohne Meldung.
|
||||||
|
//
|
||||||
|
// Das Hinzufügedatum und nicht `now`: so bleibt „zuletzt
|
||||||
|
// hinzugefügt" eine sinnvolle Reihenfolge, statt dass vierhundert
|
||||||
|
// Filme auf dieselbe Sekunde fallen.
|
||||||
|
migrationBuilder.Sql("UPDATE Movies SET PublishAt = AddedAt WHERE PublishAt IS NULL;");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE Episodes SET PublishAt = (
|
||||||
|
SELECT COALESCE(MIN(f.SeenAt), '2000-01-01 00:00:00')
|
||||||
|
FROM MediaFiles f WHERE f.EpisodeId = Episodes.Id)
|
||||||
|
WHERE PublishAt IS NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReviewNewItems",
|
||||||
|
table: "Libraries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Episodes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,9 @@ namespace Flaro.Data.Migrations
|
|||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Libraries");
|
b.ToTable("Libraries");
|
||||||
@@ -190,6 +193,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("SeasonNumber")
|
b.Property<int>("SeasonNumber")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -482,6 +488,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<bool>("PosterPinned")
|
b.Property<bool>("PosterPinned")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<double?>("Rating")
|
b.Property<double?>("Rating")
|
||||||
.HasColumnType("REAL");
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
@@ -599,6 +608,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<bool>("PosterPinned")
|
b.Property<bool>("PosterPinned")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<double?>("Rating")
|
b.Property<double?>("Rating")
|
||||||
.HasColumnType("REAL");
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Flaro.Core.Library;
|
||||||
|
|
||||||
|
namespace Flaro.Data;
|
||||||
|
|
||||||
|
/// <summary>Was ein Benutzer sehen darf — an genau einer Stelle entschieden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine Methode, nicht zwanzig Bedingungen.</b> Auf <c>db.Movies</c> und
|
||||||
|
/// <c>db.Series</c> wird an rund zwanzig Stellen in vier Dateien zugegriffen —
|
||||||
|
/// Mediathek, Suche, Vorschläge, Weiterschauen, Zuordnung, Verwaltung. Würde
|
||||||
|
/// jede ihren eigenen Filter schreiben, reichte eine vergessene, damit
|
||||||
|
/// unveröffentlichter Inhalt dort trotzdem auftaucht. Und bemerken würde man
|
||||||
|
/// es nicht beim Programmieren, sondern wenn jemand im Haushalt einen Film
|
||||||
|
/// sieht, den er nicht sehen sollte.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Gerechnet, nicht gespeichert.</b> Sichtbarkeit ist
|
||||||
|
/// <c>PublishAt != null && PublishAt <= jetzt</c> — eine Rechnung,
|
||||||
|
/// kein Zustand. Damit stimmt sie nach einem Neustart, nach einer
|
||||||
|
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
|
||||||
|
/// nicht lief.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Sichtbarkeit
|
||||||
|
{
|
||||||
|
/// <summary>Nur veröffentlichte Filme — Administratoren sehen alles.</summary>
|
||||||
|
public static IQueryable<Movie> Sichtbar(
|
||||||
|
this IQueryable<Movie> abfrage, DateTimeOffset jetzt, bool fuerAdmin) =>
|
||||||
|
fuerAdmin ? abfrage : abfrage.Where(m => m.PublishAt != null && m.PublishAt <= jetzt);
|
||||||
|
|
||||||
|
/// <summary>Nur veröffentlichte Folgen.</summary>
|
||||||
|
public static IQueryable<Episode> Sichtbar(
|
||||||
|
this IQueryable<Episode> abfrage, DateTimeOffset jetzt, bool fuerAdmin) =>
|
||||||
|
fuerAdmin ? abfrage : abfrage.Where(f => f.PublishAt != null && f.PublishAt <= jetzt);
|
||||||
|
|
||||||
|
/// <summary>Eine Serie, sobald eine ihrer Folgen sichtbar ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Kein eigenes Feld an der Serie.</b> Sonst gäbe es zwei Wahrheiten —
|
||||||
|
/// eine Serie, die als veröffentlicht gilt, deren Folgen aber alle noch
|
||||||
|
/// warten, wäre ein leerer Eintrag in der Mediathek.
|
||||||
|
/// </remarks>
|
||||||
|
public static IQueryable<Series> Sichtbar(
|
||||||
|
this IQueryable<Series> abfrage, FlaroContext db, DateTimeOffset jetzt, bool fuerAdmin) =>
|
||||||
|
fuerAdmin
|
||||||
|
? abfrage
|
||||||
|
: abfrage.Where(s => db.Episodes.Any(
|
||||||
|
f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= jetzt));
|
||||||
|
}
|
||||||
@@ -87,6 +87,23 @@ public sealed class FlaroServer : IDisposable
|
|||||||
return (await antwort.Content.ReadFromJsonAsync<LoginResponse>())!;
|
return (await antwort.Content.ReadFromJsonAsync<LoginResponse>())!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Legt einen gewöhnlichen Benutzer an und meldet ihn an.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<HttpClient> 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Ein eigener Client, der ein Token vorzeigt.</summary>
|
/// <summary>Ein eigener Client, der ein Token vorzeigt.</summary>
|
||||||
public HttpClient AlsAngemeldeter(string token)
|
public HttpClient AlsAngemeldeter(string token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Was auf Prüfung wartet, sieht niemand ausser Administratoren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Route-Aufzählung unten ist der eigentliche Test.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deshalb prüft dieser Test nicht eine Abfrage, sondern <b>jede Route, die
|
||||||
|
/// Inhalt ausliefert</b> — und zwar auf die Abwesenheit eines Titels. Eine
|
||||||
|
/// neue Route, die den Filter umgeht, fällt hier durch.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Jede Route, die Titel ausliefert.</summary>
|
||||||
|
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<LibraryResponse>())!;
|
||||||
|
|
||||||
|
await Scannen(alsAdmin, bibliothek.Id);
|
||||||
|
|
||||||
|
// Der Administrator sieht ihn — sonst könnte er ihn nicht prüfen.
|
||||||
|
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/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<LibraryResponse>())!;
|
||||||
|
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" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Zeitpunkt in der Zukunft hält ihn zurück — ohne Hintergrunddienst.</summary>
|
||||||
|
[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<LibraryResponse>())!;
|
||||||
|
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<List<Pruefeintrag>>("/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" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.</summary>
|
||||||
|
[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<LibraryResponse>())!;
|
||||||
|
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<List<Pruefeintrag>>("/api/v1/review"), Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string[]> Titel(HttpClient client) =>
|
||||||
|
(await client.GetFromJsonAsync<Page<MovieResponse>>("/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<ScanStatus>($"/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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-2
@@ -83,6 +83,9 @@ export type Bibliothek = {
|
|||||||
path: string;
|
path: string;
|
||||||
/** `null` heisst „nicht gezählt", nicht „leer". */
|
/** `null` heisst „nicht gezählt", nicht „leer". */
|
||||||
itemCount: number | null;
|
itemCount: number | null;
|
||||||
|
|
||||||
|
/** Ob neue Funde erst geprüft werden. */
|
||||||
|
reviewNewItems: boolean;
|
||||||
};
|
};
|
||||||
export type Seite<T> = { items: T[]; total: number };
|
export type Seite<T> = { items: T[]; total: number };
|
||||||
export type Lieferung = {
|
export type Lieferung = {
|
||||||
@@ -516,6 +519,31 @@ export const bildHochladen = async (
|
|||||||
{ method: 'POST', body: inhalt });
|
{ 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<Pruefeintrag[]>('/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<void>(`/libraries/${bibliothek}/review`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
|
||||||
|
|
||||||
export type Protokolltag = { date: string; bytes: number };
|
export type Protokolltag = { date: string; bytes: number };
|
||||||
|
|
||||||
/** Welche Tage protokolliert sind, neueste zuerst. */
|
/** Welche Tage protokolliert sind, neueste zuerst. */
|
||||||
@@ -560,9 +588,12 @@ export const bibliothekUmbenennen = (id: string, name: string) =>
|
|||||||
export const bibliothekLoeschen = (id: string) =>
|
export const bibliothekLoeschen = (id: string) =>
|
||||||
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
|
anfrage<void>(`/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<Bibliothek>('/libraries', {
|
anfrage<Bibliothek>('/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) =>
|
export const scanStarten = (id: string) =>
|
||||||
|
|||||||
@@ -929,3 +929,12 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
|
|||||||
background: var(--accent); color: #fff;
|
background: var(--accent); color: #fff;
|
||||||
}
|
}
|
||||||
.bildwahl-marke svg { width: 14px; height: 14px; }
|
.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; }
|
||||||
|
|||||||
+154
-4
@@ -3,7 +3,8 @@ import {
|
|||||||
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
||||||
vermisste, vermisstesEntfernen, type VermissteDatei,
|
vermisste, vermisstesEntfernen, type VermissteDatei,
|
||||||
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
||||||
meineSprache,
|
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
|
||||||
|
type Pruefeintrag,
|
||||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||||
servernameSetzen, systemauskunft,
|
servernameSetzen, systemauskunft,
|
||||||
laufendeWiedergaben, serverzustand, bildAdresse,
|
laufendeWiedergaben, serverzustand, bildAdresse,
|
||||||
@@ -27,7 +28,7 @@ export function verwaltung(
|
|||||||
signal: AbortSignal = new AbortController().signal,
|
signal: AbortSignal = new AbortController().signal,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const wurzel = document.createElement('div');
|
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),
|
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
|
||||||
protokollbereich());
|
protokollbereich());
|
||||||
return wurzel;
|
return wurzel;
|
||||||
@@ -161,6 +162,134 @@ function laufzeit(sekunden: number): string {
|
|||||||
return `${minuten} Min.`;
|
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<void> {
|
||||||
|
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 = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
|
||||||
|
+ `<span class="winzig">${esc(stand)}</span>`;
|
||||||
|
|
||||||
|
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 ---------- */
|
/* ---------- Protokolle ---------- */
|
||||||
|
|
||||||
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
|
/** 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 — '
|
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
|
||||||
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
|
+ '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',
|
bestaetigen: 'Anlegen',
|
||||||
tun: async wert => {
|
tun: async wert => {
|
||||||
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'));
|
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'),
|
||||||
|
wert('pruefen') === 'true');
|
||||||
await laden();
|
await laden();
|
||||||
geaendert();
|
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');
|
const knoepfe = document.createElement('div');
|
||||||
knoepfe.className = 'knopfreihe';
|
knoepfe.className = 'knopfreihe';
|
||||||
knoepfe.append(los, auffrischen, umbenennen, entfernen);
|
knoepfe.append(los, auffrischen, pruefung, umbenennen, entfernen);
|
||||||
d.append(knoepfe, stand);
|
d.append(knoepfe, stand);
|
||||||
void verfolgen(false);
|
void verfolgen(false);
|
||||||
return d;
|
return d;
|
||||||
|
|||||||
Reference in New Issue
Block a user