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:
Scarriffle
2026-10-04 12:43:52 +02:00
co-authored by Claude Opus 5
parent e1399c808f
commit eb63271e92
16 changed files with 1759 additions and 11 deletions
@@ -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> /// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
public sealed record SprachStand(string? Language, int Translated, int Total); 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> /// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes); public sealed record Protokolltag(string Date, long Bytes);
+1
View File
@@ -185,6 +185,7 @@ app.MapImages();
app.MapDelivery(); app.MapDelivery();
app.MapPlayback(); app.MapPlayback();
app.MapMatching(); app.MapMatching();
app.MapArtwork();
// **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
+30 -2
View File
@@ -78,7 +78,7 @@ public sealed class MediaFile
public string? VersionLabel { get; set; } public string? VersionLabel { get; set; }
} }
public sealed class Movie public sealed class Movie : Flaro.Core.Metadata.IBebildert
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; } public required Guid LibraryId { get; init; }
@@ -110,6 +110,20 @@ public sealed class Movie
/// <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>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
///
/// <remarks>
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
/// Merker zwänge zu einer Entscheidung für alle drei.
///
/// Ohne das überschreibt der nächste Lauf von
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
/// </remarks>
public bool PosterPinned { get; set; }
public bool BackdropPinned { get; set; }
public bool LogoPinned { get; set; }
/// <summary>Der Titel in der Sprache des Originals.</summary> /// <summary>Der Titel in der Sprache des Originals.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -185,7 +199,7 @@ public sealed class Movie
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
} }
public sealed class Series public sealed class Series : Flaro.Core.Metadata.IBebildert
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; } public required Guid LibraryId { get; init; }
@@ -215,6 +229,20 @@ public sealed class Series
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>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
///
/// <remarks>
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
/// Merker zwänge zu einer Entscheidung für alle drei.
///
/// Ohne das überschreibt der nächste Lauf von
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
/// </remarks>
public bool PosterPinned { get; set; }
public bool BackdropPinned { get; set; }
public bool LogoPinned { get; set; }
/// <summary>Der Titel in der Sprache des Originals.</summary> /// <summary>Der Titel in der Sprache des Originals.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -174,6 +174,38 @@ public interface IImageStore
{ {
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary> /// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default); Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
///
/// <remarks>
/// Für eigene Plakate. Dieselbe Ablage wie für geholte — damit ein Bild
/// unter derselben Adresse liegt, egal woher es stammt.
/// </remarks>
/// <remarks>
/// <b>Mit Vorgabe.</b> Eine Ablage, die nur Geholtes kennt, lehnt das
/// Hochladen ab — das ist ehrlicher als eine Pflichtmethode, die jeder
/// Test-Doppelgänger nachbauen müsste, obwohl er sie nie braucht.
/// </remarks>
Task<string?> StoreStreamAsync(Stream inhalt, string? medientyp, CancellationToken ct = default) =>
Task.FromResult<string?>(null);
}
/// <summary>Was Bilder trägt — Film oder Serie.</summary>
///
/// <remarks>
/// <b>Damit das Setzen eines Plakats nur einmal geschrieben werden muss.</b>
/// Film und Serie tragen dieselben sechs Eigenschaften; ohne gemeinsame
/// Schnittstelle stünde derselbe Code zweimal da und liefe irgendwann
/// auseinander.
/// </remarks>
public interface IBebildert
{
string? PosterImage { get; set; }
string? BackdropImage { get; set; }
string? LogoImage { get; set; }
bool PosterPinned { get; set; }
bool BackdropPinned { get; set; }
bool LogoPinned { get; set; }
} }
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary> /// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
@@ -186,3 +218,12 @@ public interface IImageReader
{ {
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default); Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
} }
/// <summary>Ein Bild zur Auswahl — Plakat, Hintergrund oder Schriftzug.</summary>
///
/// <remarks>
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
/// </remarks>
public sealed record Bildkandidat(
string Kind, string Url, string ThumbUrl, string? Language, double? Vote);
+12 -6
View File
@@ -108,9 +108,12 @@ public sealed class MetadataEnricher(
film.SortTitleValue = SortTitle.For(gefunden.Title); film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year; film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview; film.Overview = gefunden.Overview;
film.PosterImage = await BildAblegen(gefunden.Poster, ct); // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); // überschreibt jedes Auffrischen die Auswahl wieder — und wer
film.LogoImage = await BildAblegen(gefunden.Logo, ct); // einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
if (!film.PosterPinned) film.PosterImage = await BildAblegen(gefunden.Poster, ct);
if (!film.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
film.RuntimeMinutes = gefunden.RuntimeMinutes; film.RuntimeMinutes = gefunden.RuntimeMinutes;
film.Rating = gefunden.Rating; film.Rating = gefunden.Rating;
film.Tagline = gefunden.Tagline; film.Tagline = gefunden.Tagline;
@@ -185,9 +188,12 @@ public sealed class MetadataEnricher(
serie.SortTitleValue = SortTitle.For(gefunden.Title); serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year; serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview; serie.Overview = gefunden.Overview;
serie.PosterImage = await BildAblegen(gefunden.Poster, ct); // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); // überschreibt jedes Auffrischen die Auswahl wieder — und wer
serie.LogoImage = await BildAblegen(gefunden.Logo, ct); // einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
if (!serie.PosterPinned) serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
if (!serie.BackdropPinned) serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
if (!serie.LogoPinned) serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
serie.RuntimeMinutes = gefunden.RuntimeMinutes; serie.RuntimeMinutes = gefunden.RuntimeMinutes;
serie.Rating = gefunden.Rating; serie.Rating = gefunden.Rating;
serie.Tagline = gefunden.Tagline; serie.Tagline = gefunden.Tagline;
@@ -0,0 +1,870 @@
// <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("20261004103857_Bildauswahl")]
partial class Bildauswahl
{
/// <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.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<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<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<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,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Bildauswahl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BackdropPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LogoPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PosterPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "BackdropPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LogoPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PosterPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BackdropPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "LogoPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "PosterPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "BackdropPinned",
table: "Movies");
migrationBuilder.DropColumn(
name: "LogoPinned",
table: "Movies");
migrationBuilder.DropColumn(
name: "PosterPinned",
table: "Movies");
}
}
}
@@ -439,6 +439,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("BackdropImage") b.Property<string>("BackdropImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating") b.Property<string>("ContentRating")
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -453,6 +456,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("LogoImage") b.Property<string>("LogoImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked") b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -473,6 +479,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("PosterImage") b.Property<string>("PosterImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<double?>("Rating") b.Property<double?>("Rating")
.HasColumnType("REAL"); .HasColumnType("REAL");
@@ -547,6 +556,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("BackdropImage") b.Property<string>("BackdropImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating") b.Property<string>("ContentRating")
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -561,6 +573,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("LogoImage") b.Property<string>("LogoImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked") b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -581,6 +596,9 @@ namespace Flaro.Data.Migrations
b.Property<string>("PosterImage") b.Property<string>("PosterImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<double?>("Rating") b.Property<double?>("Rating")
.HasColumnType("REAL"); .HasColumnType("REAL");
+46
View File
@@ -31,6 +31,52 @@ public sealed class ImageCache(
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary> /// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
private const long HöchstensBytes = 16 * 1024 * 1024; private const long HöchstensBytes = 16 * 1024 * 1024;
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
///
/// <remarks>
/// <b>Derselbe Ort wie für geholte Bilder.</b> Ein eigenes Plakat liegt
/// damit unter derselben Adresse wie eins von TMDB, und nichts weiter im
/// Programm muss wissen, woher es kam.
///
/// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert:
/// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei.
/// </remarks>
public async Task<string?> StoreStreamAsync(
Stream inhalt, string? medientyp, CancellationToken ct = default)
{
using var zwischen = new MemoryStream();
await inhalt.CopyToAsync(zwischen, ct);
if (zwischen.Length == 0 || zwischen.Length > HöchstensBytes) return null;
var daten = zwischen.ToArray();
var endung = medientyp switch
{
"image/png" => ".png",
"image/webp" => ".webp",
_ => ".jpg",
};
var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten))[..32]
+ endung;
var ziel = PfadFür(schlüssel);
if (ziel is null) return null;
if (File.Exists(ziel)) return schlüssel;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
var vorlaeufig = ziel + ".teil";
await File.WriteAllBytesAsync(vorlaeufig, daten, ct);
File.Move(vorlaeufig, ziel, overwrite: true);
return schlüssel;
}
catch (IOException fehler)
{
log.LogWarning(fehler, "Hochgeladenes Bild liess sich nicht ablegen.");
return null;
}
}
public async Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default) public async Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
{ {
var schlüssel = Schlüssel(bild.Url); var schlüssel = Schlüssel(bild.Url);
+51 -1
View File
@@ -352,6 +352,54 @@ public sealed class TmdbClient(
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort /// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
/// um Bildqualität geht und nicht mehr um die Sprache. /// um Bildqualität geht und nicht mehr um die Sprache.
/// </remarks> /// </remarks>
/// <summary>Alle Bilder zu einem Titel — für die Auswahl von Hand.</summary>
///
/// <remarks>
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
/// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
/// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
/// Anfrage.
/// </remarks>
public async Task<IReadOnlyList<Bildkandidat>> BilderAsync(
int providerId, bool serie, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return [];
var sprache = Sprachkürzel(einstellung.Language);
var weg = serie ? "tv" : "movie";
var antwort = await HolenAsync<TmdbBilderAntwort>(
$"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
einstellung, ct);
if (antwort is null) return [];
var liste = new List<Bildkandidat>();
Sammeln(antwort.Posters, "poster", PosterSize);
Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
Sammeln(antwort.Logos, "logo", "w500");
return liste;
void Sammeln(List<TmdbImage>? bilder, string art, string groesse)
{
foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath))
.OrderByDescending(b => b.VoteAverage ?? 0)
.Take(24))
{
liste.Add(new Bildkandidat(
art, Bild(b.FilePath, groesse)!.Url,
Bild(b.FilePath, "w185")!.Url,
string.IsNullOrEmpty(b.Language) ? null : b.Language,
b.VoteAverage));
}
}
}
private sealed record TmdbBilderAntwort(
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
{ {
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList(); var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
@@ -486,7 +534,9 @@ public sealed class TmdbClient(
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name); private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
private sealed record TmdbImages( private sealed record TmdbImages(
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos); [property: JsonPropertyName("logos")] List<TmdbImage>? Logos,
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters = null,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops = null);
private sealed record TmdbImage( private sealed record TmdbImage(
[property: JsonPropertyName("file_path")] string? FilePath, [property: JsonPropertyName("file_path")] string? FilePath,
+38 -1
View File
@@ -169,7 +169,12 @@ export class ApiFehler extends Error {
async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> { async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
const kopf = new Headers(opts.headers); const kopf = new Headers(opts.headers);
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`); if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
if (opts.body && !kopf.has('Content-Type')) kopf.set('Content-Type', 'application/json'); // **Bei FormData nicht setzen.** Der Browser schreibt dort
// `multipart/form-data` mitsamt der Grenzmarkierung selbst; ein von Hand
// gesetzter Kopf hätte keine, und der Server fände kein einziges Feld.
if (opts.body && !(opts.body instanceof FormData) && !kopf.has('Content-Type')) {
kopf.set('Content-Type', 'application/json');
}
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
@@ -479,6 +484,38 @@ export const uebersetzungenHolen = (language: string) =>
export const aehnliche = (id: string) => export const aehnliche = (id: string) =>
anfrage<Seite<Film>>(`/movies/${id}/similar?take=12`); anfrage<Seite<Film>>(`/movies/${id}/similar?take=12`);
export type Bildkandidat = {
kind: 'poster' | 'backdrop' | 'logo';
url: string; thumbUrl: string;
language: string | null; vote: number | null;
};
export type Bildauswahl = {
current: {
poster: string | null; backdrop: string | null; logo: string | null;
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
};
candidates: Bildkandidat[];
};
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
anfrage<void>(`/${art}/${id}/artwork`,
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
/** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser
* mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */
export const bildHochladen = async (
art: 'movies' | 'series', id: string, kind: string, datei: File,
): Promise<{ image: string }> => {
const inhalt = new FormData();
inhalt.append('datei', datei);
return anfrage<{ image: string }>(`/${art}/${id}/artwork?kind=${kind}`,
{ method: 'POST', body: inhalt });
};
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. */
+163
View File
@@ -0,0 +1,163 @@
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
import { knopf, fehlerzeile } from './util';
import { icons } from './icons';
type Bildart = 'poster' | 'backdrop' | 'logo';
const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [
{ art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3' },
{ art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9' },
{ art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9' },
];
/** Plakat, Hintergrund und Schriftzug von Hand wählen.
*
* **Erreichbar aus der Übersicht, nicht erst von der Detailseite.** Bei
* zwanzig Titeln mit schlechtem Plakat waren das vierzig Seitenwechsel.
*
* **Die Kandidaten kosten nichts.** TMDB liefert sie beim Einzelabruf ohnehin
* mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
*/
export function bilddialog(art: 'movies' | 'series', id: string, titel: string): void {
const d = document.createElement('dialog');
d.className = 'dlg dlg-breit';
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = `Bilder — ${titel}`;
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
koerper.innerHTML = '<div class="spinner"></div>';
d.append(kopf, koerper);
d.addEventListener('close', () => d.remove());
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
void laden();
async function laden(): Promise<void> {
let auswahl;
try {
auswahl = await bildauswahl(art, id);
} catch (fehler) {
koerper.innerHTML = '';
koerper.appendChild(fehlerzeile((fehler as Error).message));
return;
}
koerper.innerHTML = '';
if (auswahl.candidates.length === 0) {
const hinweis = document.createElement('p');
hinweis.className = 'hinweis';
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die Zuordnung das
// Problem, nicht das Bild, und das soll hier auch so dastehen.
hinweis.textContent = 'Keine Bilder gefunden. Ist der Titel richtig zugeordnet?';
koerper.appendChild(hinweis);
}
ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => {
const abschnitt = document.createElement('section');
abschnitt.className = 'bildblock';
const kopfzeile = document.createElement('div');
kopfzeile.className = 'knopfreihe';
const u = document.createElement('h3');
u.textContent = name;
kopfzeile.appendChild(u);
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
// das native Dateifeld in jedem Browser anders aussieht.
const feld = document.createElement('input');
feld.type = 'file';
feld.accept = 'image/*';
feld.className = 'hidden';
const eigenes = knopf('Eigenes Bild…', 'btn');
eigenes.type = 'button';
eigenes.onclick = () => feld.click();
feld.onchange = async () => {
const datei = feld.files?.[0];
if (!datei) return;
eigenes.disabled = true;
try { await bildHochladen(art, id, bildart, datei); await laden(); }
catch (fehler) {
abschnitt.appendChild(fehlerzeile((fehler as Error).message));
eigenes.disabled = false;
}
};
kopfzeile.append(eigenes, feld);
abschnitt.appendChild(kopfzeile);
const gitter = document.createElement('div');
gitter.className = 'bildgitter';
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
const aktuell = bildart === 'poster' ? auswahl.current.poster
: bildart === 'backdrop' ? auswahl.current.backdrop
: auswahl.current.logo;
const passende = auswahl.candidates.filter(k => k.kind === bildart);
if (passende.length === 0 && !aktuell) {
const leer = document.createElement('p');
leer.className = 'winzig';
leer.textContent = 'Nichts zur Auswahl.';
abschnitt.append(leer);
koerper.appendChild(abschnitt);
return;
}
// Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht.
if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart));
passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart)));
abschnitt.appendChild(gitter);
koerper.appendChild(abschnitt);
});
}
function kachelFuer(
kandidat: Bildkandidat | null, adresse: string, gesetzt: boolean, bildart: Bildart,
): HTMLElement {
const b = document.createElement('button');
b.type = 'button';
b.className = 'bildwahl' + (gesetzt ? ' an' : '');
b.title = gesetzt ? 'Aktuell gesetzt'
: [kandidat?.language?.toUpperCase(), kandidat?.vote ? `★ ${kandidat.vote.toFixed(1)}` : null]
.filter(Boolean).join(' · ') || 'Auswählen';
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.loading = 'lazy';
b.appendChild(bild);
if (gesetzt) {
const marke = document.createElement('span');
marke.className = 'bildwahl-marke';
marke.innerHTML = icons.haken;
b.appendChild(marke);
b.disabled = true;
return b;
}
b.onclick = async () => {
b.disabled = true;
try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); }
catch (fehler) {
b.after(fehlerzeile((fehler as Error).message));
b.disabled = false;
}
};
return b;
}
}
+90 -1
View File
@@ -1,6 +1,11 @@
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api'; import {
bildAdresse, gesehen, favorit, weglegen, istAdmin,
type Film, type Serie, type Angefangen, type Stand,
} from './api';
import { esc, anteil } from './util'; import { esc, anteil } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { kontextmenue, type Menuepunkt } from './menue';
import { bilddialog } from './bilder';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = { export type Kachel = {
@@ -59,6 +64,11 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string)
knopf.className = 'kachel'; knopf.className = 'kachel';
knopf.onclick = () => oeffnen(k.art, k.id); knopf.onclick = () => oeffnen(k.art, k.id);
// **Das Kontextmenü an jeder Kachel, überall.** Gesehen, Favorit und
// Weglegen gab es nur auf der Detailseite — in einer Übersicht mit zwanzig
// Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen));
const rahmen = document.createElement('div'); const rahmen = document.createElement('div');
rahmen.className = 'plakat'; rahmen.className = 'plakat';
rahmen.setAttribute('role', 'img'); rahmen.setAttribute('role', 'img');
@@ -172,3 +182,82 @@ export function reihe(
bereich.append(kopf, streifen); bereich.append(kopf, streifen);
return bereich; return bereich;
} }
/** Was im Kontextmenü einer Kachel steht.
*
* **Aktionen wirken sofort auf der Kachel**, ohne Seitenwechsel und ohne
* Neuaufbau der Liste. Den Zustand hier zu spiegeln statt neu zu laden ist
* nicht nur schneller — ein Neuaufbau verlöre Bildlauf und Filter.
*/
function kachelpunkte(
k: Kachel,
knopf: HTMLElement,
oeffnen: (art: 'filme' | 'serien', id: string) => void,
): Menuepunkt[] {
const istGesehen = k.stand?.watched === true;
const punkte: Menuepunkt[] = [
{
text: istGesehen ? 'Als ungesehen markieren' : 'Als gesehen markieren',
icon: 'hakenKreis',
tun: async () => {
await gesehen(k.id, !istGesehen);
// **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu
// laden verlöre Bildlauf und Filter — für einen Haken.
if (k.stand) k.stand.watched = !istGesehen;
hakenZeigen(knopf, !istGesehen);
},
},
{
text: k.stand?.favourite ? 'Aus Favoriten entfernen' : 'Zu Favoriten',
icon: 'stern',
tun: async () => {
const neuerWert = !k.stand?.favourite;
await favorit(k.id, neuerWert);
if (k.stand) k.stand.favourite = neuerWert;
},
},
];
// Nur anbieten, wenn es dort auch steht — ein Knopf, der nichts tut, ist
// schlechter als keiner.
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
punkte.push({
text: 'Aus Weiterschauen nehmen',
tun: async () => {
await weglegen(k.id);
if (k.stand) k.stand.dismissed = true;
knopf.querySelector('.fortschritt')?.remove();
},
});
}
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
if (istAdmin()) {
punkte.push({
text: 'Bilder bearbeiten',
icon: 'stift',
tun: () => bilddialog(k.art === 'serien' ? 'series' : 'movies', k.id, k.titel),
});
}
return punkte;
}
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */
function hakenZeigen(knopf: HTMLElement, an: boolean): void {
const plakat = knopf.querySelector('.plakat');
if (!plakat) return;
plakat.querySelector('.ecke.haken')?.remove();
knopf.querySelector('.fortschritt')?.remove();
if (!an) return;
const ecke = document.createElement('span');
ecke.className = 'ecke haken';
ecke.title = 'Gesehen';
ecke.innerHTML = icons.haken;
plakat.appendChild(ecke);
}
+113
View File
@@ -0,0 +1,113 @@
import { icons } from './icons';
export type Menuepunkt = {
text: string;
icon?: keyof typeof icons;
/** Rot und unten abgesetzt — für alles, was etwas wegnimmt. */
gefaehrlich?: boolean;
tun: () => void | Promise<void>;
};
/** Ein Kontextmenü auf einer Kachel.
*
* **Warum es das braucht.** Gesehen, Favorit und Weglegen gab es
* ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln
* hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
*
* **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.
*/
export function kontextmenue(ziel: HTMLElement, punkte: () => Menuepunkt[]): void {
ziel.addEventListener('contextmenu', e => {
e.preventDefault();
oeffnen(e.clientX, e.clientY, punkte());
});
// Die Kontextmenü-Taste und Shift+F10 — dieselbe Geste ohne Maus.
ziel.addEventListener('keydown', e => {
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
e.preventDefault();
const kasten = ziel.getBoundingClientRect();
oeffnen(kasten.left + kasten.width / 2, kasten.top + kasten.height / 2, punkte());
});
// Langer Druck. 500 ms, und jede Bewegung bricht ab — sonst löst Scrollen
// das Menü aus, und das Scrollen hört dabei auf.
let druck: number | undefined;
let start = { x: 0, y: 0 };
ziel.addEventListener('touchstart', e => {
const f = e.touches[0];
if (!f) return;
start = { x: f.clientX, y: f.clientY };
druck = window.setTimeout(() => oeffnen(start.x, start.y, punkte()), 500);
}, { passive: true });
const abbrechen = () => { if (druck !== undefined) { clearTimeout(druck); druck = undefined; } };
ziel.addEventListener('touchend', abbrechen);
ziel.addEventListener('touchcancel', abbrechen);
ziel.addEventListener('touchmove', e => {
const f = e.touches[0];
if (!f) return;
if (Math.abs(f.clientX - start.x) > 8 || Math.abs(f.clientY - start.y) > 8) abbrechen();
}, { passive: true });
}
/** Das offene Menü — immer höchstens eines. */
let offen: HTMLElement | null = null;
export function menueSchliessen(): void {
offen?.remove();
offen = null;
}
function oeffnen(x: number, y: number, punkte: Menuepunkt[]): void {
menueSchliessen();
if (punkte.length === 0) return;
const menue = document.createElement('div');
menue.className = 'kontextmenue';
menue.setAttribute('role', 'menu');
punkte.forEach(p => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'kontextpunkt' + (p.gefaehrlich ? ' gefahr' : '');
b.setAttribute('role', 'menuitem');
if (p.icon) {
const i = document.createElement('span');
i.className = 'icon';
i.innerHTML = icons[p.icon];
b.appendChild(i);
}
const t = document.createElement('span');
t.textContent = p.text;
b.appendChild(t);
b.onclick = async () => { menueSchliessen(); await p.tun(); };
menue.appendChild(b);
});
document.body.appendChild(menue);
offen = menue;
// **Erst anhängen, dann messen.** Vorher hat das Menü keine Grösse, und
// die Prüfung auf den Bildschirmrand liefe gegen null.
const kasten = menue.getBoundingClientRect();
const links = Math.min(x, window.innerWidth - kasten.width - 8);
const oben = Math.min(y, window.innerHeight - kasten.height - 8);
menue.style.left = `${Math.max(8, links)}px`;
menue.style.top = `${Math.max(8, oben)}px`;
menue.querySelector<HTMLElement>('button')?.focus();
}
// Ein Klick daneben, Escape oder ein Bildlauf schliessen. Einmal global
// registriert und nicht je Kachel — sonst hingen an vierhundert Kacheln
// zwölfhundert Zuhörer am Dokument.
document.addEventListener('click', e => {
if (offen && !offen.contains(e.target as Node)) menueSchliessen();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') menueSchliessen(); });
window.addEventListener('scroll', menueSchliessen, { passive: true });
+59
View File
@@ -870,3 +870,62 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.hero-inhalt { padding: var(--s-loose); max-width: none; } .hero-inhalt { padding: var(--s-loose); max-width: none; }
.hero-punkte { right: var(--s-loose); bottom: var(--s-loose); } .hero-punkte { right: var(--s-loose); bottom: var(--s-loose); }
} }
/* --- Kontextmenü ---------------------------------------------------------
*
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
* Übersicht mit zwanzig Titeln hiess „die hab ich alle gesehen" vierzig
* Seitenwechsel. */
.kontextmenue {
position: fixed; z-index: 100;
min-width: 220px; padding: var(--s-tight);
background: var(--elevated);
border: 1px solid var(--line); border-radius: var(--r-flaeche);
box-shadow: var(--schatten-dialog);
display: flex; flex-direction: column;
}
.kontextpunkt {
display: flex; align-items: center; gap: var(--s-snug);
background: none; border: 0; border-radius: var(--r-knopf);
padding: 8px var(--s-regular); text-align: left;
color: var(--text-dim); font-size: var(--t-normal);
}
.kontextpunkt:hover { background: var(--raised); color: var(--text); }
.kontextpunkt svg { width: 17px; height: 17px; }
.kontextpunkt.gefahr { color: var(--destructive); }
/* --- Bildauswahl ---------------------------------------------------------- */
.dlg-breit { width: min(860px, 100vw - 48px); }
.bildblock { display: flex; flex-direction: column; gap: var(--s-snug); }
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
.bildgitter {
display: grid; gap: var(--s-snug);
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
max-height: 260px; overflow-y: auto;
padding: 2px;
}
.bildwahl {
position: relative; padding: 0; border: 2px solid transparent;
border-radius: var(--r-karte); overflow: hidden;
background: var(--placeholder);
aspect-ratio: var(--verhaeltnis, 2 / 3);
}
.bildwahl img { width: 100%; height: 100%; object-fit: contain; display: block; }
.bildwahl:hover { border-color: var(--accent); }
/* Das gesetzte Bild ist markiert und nicht anklickbar — es ist schon gewählt. */
.bildwahl.an { border-color: var(--accent); opacity: 1; }
.bildwahl:disabled { cursor: default; }
.bildwahl-marke {
position: absolute; right: 4px; top: 4px;
display: grid; place-items: center;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #fff;
}
.bildwahl-marke svg { width: 14px; height: 14px; }