Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl
Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen" vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger contextmenu-Zuhörer. Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst löst Scrollen ihn aus. Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre Bildlauf und Filter — für einen Haken. Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat, Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit, und bisher wurde alles ausser dem Schriftzug weggeworfen. Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht hat, suchte es jedes Mal neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1399c808f
commit
eb63271e92
@@ -0,0 +1,133 @@
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
|
||||
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
|
||||
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
|
||||
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ArtworkEndpoints
|
||||
{
|
||||
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
|
||||
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
|
||||
/// </remarks>
|
||||
private const long HoechstensBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static void MapArtwork(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var gruppe = app.MapGroup("/api/v1")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
|
||||
{
|
||||
var serie = art == "series";
|
||||
var kennung = serie
|
||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||
|
||||
var aktuell = serie
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
||||
|
||||
if (aktuell is null) return Results.NotFound();
|
||||
|
||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||
// Zuordnung das Problem, nicht das Bild.
|
||||
var kandidaten = kennung is { } k
|
||||
? await tmdb.BilderAsync(k, serie, ct)
|
||||
: [];
|
||||
|
||||
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||
});
|
||||
|
||||
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, BildWahlRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
|
||||
|
||||
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
|
||||
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
|
||||
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
|
||||
? null
|
||||
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
|
||||
|
||||
if (anfrage.Url is { Length: > 0 } && schluessel is null)
|
||||
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, string kind, HttpRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
|
||||
if (!anfrage.HasFormContentType) return Results.BadRequest(new { fehler = "Kein Formular." });
|
||||
|
||||
var form = await anfrage.ReadFormAsync(ct);
|
||||
var datei = form.Files.FirstOrDefault();
|
||||
if (datei is null || datei.Length == 0)
|
||||
return Results.BadRequest(new { fehler = "Keine Datei dabei." });
|
||||
if (datei.Length > HoechstensBytes)
|
||||
return Results.BadRequest(new { fehler = "Höchstens 10 MB." });
|
||||
|
||||
await using var strom = datei.OpenReadStream();
|
||||
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
|
||||
}).DisableAntiforgery();
|
||||
}
|
||||
|
||||
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
|
||||
private static async Task<bool> Setzen<T>(
|
||||
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
|
||||
where T : class, IBebildert
|
||||
{
|
||||
var betroffen = art switch
|
||||
{
|
||||
"poster" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.PosterImage, schluessel)
|
||||
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
|
||||
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.BackdropImage, schluessel)
|
||||
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
|
||||
_ => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.LogoImage, schluessel)
|
||||
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
|
||||
};
|
||||
|
||||
return betroffen > 0;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,16 @@ public sealed record SprachRequest(string? Language);
|
||||
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
|
||||
public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||
|
||||
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||
public sealed record Bildstand(
|
||||
string? Poster, string? Backdrop, string? Logo,
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
||||
|
||||
public sealed record Bildauswahl(
|
||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||
|
||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||
|
||||
/// <summary>Ein Protokolltag.</summary>
|
||||
public sealed record Protokolltag(string Date, long Bytes);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user