diff --git a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs
new file mode 100644
index 0000000..c0b212b
--- /dev/null
+++ b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs
@@ -0,0 +1,133 @@
+using Flaro.Core.Metadata;
+using Flaro.Data;
+using Flaro.Metadata;
+using Microsoft.EntityFrameworkCore;
+
+namespace Flaro.Api.Endpoints;
+
+/// Plakat, Hintergrund und Schriftzug von Hand wählen.
+///
+///
+///
+/// Wofür. 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.
+///
+///
+/// Die Kandidaten kosten nichts. TMDB liefert sie beim Einzelabruf
+/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
+///
+///
+public static class ArtworkEndpoints
+{
+ /// Höchstens so gross darf ein eigenes Bild sein.
+ ///
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ /// Setzt ein Bild und merkt sich, dass es von Hand kam.
+ private static async Task Setzen(
+ IQueryable 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;
+ }
+}
diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 6251411..0f42a61 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -66,6 +66,16 @@ public sealed record SprachRequest(string? Language);
/// Welche Sprache gilt — und wie weit sie abgedeckt ist.
public sealed record SprachStand(string? Language, int Translated, int Total);
+/// Welche Bilder gerade gesetzt sind — und welche von Hand.
+public sealed record Bildstand(
+ string? Poster, string? Backdrop, string? Logo,
+ bool PosterPinned, bool BackdropPinned, bool LogoPinned);
+
+public sealed record Bildauswahl(
+ Bildstand Current, IReadOnlyList Candidates);
+
+public sealed record BildWahlRequest(string Kind, string? Url);
+
/// Ein Protokolltag.
public sealed record Protokolltag(string Date, long Bytes);
diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs
index 5af312b..a44b6c8 100644
--- a/server/src/Flaro.Api/Program.cs
+++ b/server/src/Flaro.Api/Program.cs
@@ -185,6 +185,7 @@ app.MapImages();
app.MapDelivery();
app.MapPlayback();
app.MapMatching();
+app.MapArtwork();
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs
index 893077c..e149532 100644
--- a/server/src/Flaro.Core/Library/MediaEntities.cs
+++ b/server/src/Flaro.Core/Library/MediaEntities.cs
@@ -78,7 +78,7 @@ public sealed class MediaFile
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 required Guid LibraryId { get; init; }
@@ -110,6 +110,20 @@ public sealed class Movie
/// Ohne führenden Artikel — siehe .
public required string SortTitleValue { get; set; }
+ /// Von Hand gewählt — ein Auffrischen überschreibt es nicht.
+ ///
+ ///
+ /// Getrennt je Bild, nicht als ein Schalter. 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
+ /// EnrichAsync(…, erneut: true) jede Handauswahl wieder.
+ ///
+ public bool PosterPinned { get; set; }
+ public bool BackdropPinned { get; set; }
+ public bool LogoPinned { get; set; }
+
/// Der Titel in der Sprache des Originals.
///
///
@@ -185,7 +199,7 @@ public sealed class Movie
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 required Guid LibraryId { get; init; }
@@ -215,6 +229,20 @@ public sealed class Series
public required string Title { get; set; }
public required string SortTitleValue { get; set; }
+ /// Von Hand gewählt — ein Auffrischen überschreibt es nicht.
+ ///
+ ///
+ /// Getrennt je Bild, nicht als ein Schalter. 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
+ /// EnrichAsync(…, erneut: true) jede Handauswahl wieder.
+ ///
+ public bool PosterPinned { get; set; }
+ public bool BackdropPinned { get; set; }
+ public bool LogoPinned { get; set; }
+
/// Der Titel in der Sprache des Originals.
///
///
diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
index 738016b..dcac810 100644
--- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
+++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
@@ -174,6 +174,38 @@ public interface IImageStore
{
/// Holt das Bild und gibt seinen Schlüssel zurück — oder null.
Task StoreAsync(RemoteImage bild, CancellationToken ct = default);
+
+ /// Legt ein hochgeladenes Bild ab.
+ ///
+ ///
+ /// Für eigene Plakate. Dieselbe Ablage wie für geholte — damit ein Bild
+ /// unter derselben Adresse liegt, egal woher es stammt.
+ ///
+ ///
+ /// Mit Vorgabe. 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.
+ ///
+ Task StoreStreamAsync(Stream inhalt, string? medientyp, CancellationToken ct = default) =>
+ Task.FromResult(null);
+}
+
+/// Was Bilder trägt — Film oder Serie.
+///
+///
+/// Damit das Setzen eines Plakats nur einmal geschrieben werden muss.
+/// Film und Serie tragen dieselben sechs Eigenschaften; ohne gemeinsame
+/// Schnittstelle stünde derselbe Code zweimal da und liefe irgendwann
+/// auseinander.
+///
+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; }
}
/// Ein Bild aus der Ablage wieder herausgeben.
@@ -186,3 +218,12 @@ public interface IImageReader
{
Task OpenAsync(string schlüssel, CancellationToken ct = default);
}
+
+/// Ein Bild zur Auswahl — Plakat, Hintergrund oder Schriftzug.
+///
+///
+/// Mit Vorschau und Sprache. Wer ein Plakat aussucht, sucht meist eins
+/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
+///
+public sealed record Bildkandidat(
+ string Kind, string Url, string ThumbUrl, string? Language, double? Vote);
diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs
index 859ccd6..b1aea03 100644
--- a/server/src/Flaro.Data/MetadataEnricher.cs
+++ b/server/src/Flaro.Data/MetadataEnricher.cs
@@ -108,9 +108,12 @@ public sealed class MetadataEnricher(
film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
- film.PosterImage = await BildAblegen(gefunden.Poster, ct);
- film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
- film.LogoImage = await BildAblegen(gefunden.Logo, ct);
+ // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
+ // überschreibt jedes Auffrischen die Auswahl wieder — und wer
+ // 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.Rating = gefunden.Rating;
film.Tagline = gefunden.Tagline;
@@ -185,9 +188,12 @@ public sealed class MetadataEnricher(
serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
- serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
- serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
- serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
+ // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
+ // überschreibt jedes Auffrischen die Auswahl wieder — und wer
+ // 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.Rating = gefunden.Rating;
serie.Tagline = gefunden.Tagline;
diff --git a/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.Designer.cs b/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.Designer.cs
new file mode 100644
index 0000000..683c8ae
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.Designer.cs
@@ -0,0 +1,870 @@
+//
+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
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
+
+ modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("HandleFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code");
+
+ b.HasIndex("HandleFingerprint")
+ .IsUnique();
+
+ b.ToTable("AuthCodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenFingerprint")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("DeviceTokens");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Libraries");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Character")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("PersonId")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("PersonId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("Credits");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeasonNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("StillImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
+ .IsUnique();
+
+ b.ToTable("Episodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GenreId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("GenreId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("GenreId", "SeriesId")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("GenreLinks");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("FetchedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId", "Language")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("MovieId", "Language")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("SeriesId", "Language")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("Translations");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("VersionLabel")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("LibraryId", "Path")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Movies");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Person", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Image")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TmdbId")
+ .IsUnique();
+
+ b.ToTable("People");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Dismissed")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("Favourite")
+ .HasColumnType("INTEGER");
+
+ b.Property("InProgress")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Position")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Runtime")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Watched")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("UserId", "EpisodeId")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "InProgress", "UpdatedAt");
+
+ b.ToTable("UserItemStates");
+ });
+
+ modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdministrator")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserName")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Person", null)
+ .WithMany()
+ .HasForeignKey("PersonId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenreId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.HasOne("Flaro.Core.Library.MediaFile", null)
+ .WithMany()
+ .HasForeignKey("MediaFileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Episode", null)
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.cs b/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.cs
new file mode 100644
index 0000000..11a7c0f
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261004103857_Bildauswahl.cs
@@ -0,0 +1,84 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ ///
+ public partial class Bildauswahl : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "BackdropPinned",
+ table: "Series",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "LogoPinned",
+ table: "Series",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "PosterPinned",
+ table: "Series",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "BackdropPinned",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "LogoPinned",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "PosterPinned",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ 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");
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
index 793c220..5f73605 100644
--- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
+++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
@@ -439,6 +439,9 @@ namespace Flaro.Data.Migrations
b.Property("BackdropImage")
.HasColumnType("TEXT");
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
b.Property("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
@@ -453,6 +456,9 @@ namespace Flaro.Data.Migrations
b.Property("LogoImage")
.HasColumnType("TEXT");
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
b.Property("MatchLocked")
.HasColumnType("INTEGER");
@@ -473,6 +479,9 @@ namespace Flaro.Data.Migrations
b.Property("PosterImage")
.HasColumnType("TEXT");
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
b.Property("Rating")
.HasColumnType("REAL");
@@ -547,6 +556,9 @@ namespace Flaro.Data.Migrations
b.Property("BackdropImage")
.HasColumnType("TEXT");
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
b.Property("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
@@ -561,6 +573,9 @@ namespace Flaro.Data.Migrations
b.Property("LogoImage")
.HasColumnType("TEXT");
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
b.Property("MatchLocked")
.HasColumnType("INTEGER");
@@ -581,6 +596,9 @@ namespace Flaro.Data.Migrations
b.Property("PosterImage")
.HasColumnType("TEXT");
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
b.Property("Rating")
.HasColumnType("REAL");
diff --git a/server/src/Flaro.Metadata/ImageCache.cs b/server/src/Flaro.Metadata/ImageCache.cs
index 2777f1e..ddfcb26 100644
--- a/server/src/Flaro.Metadata/ImageCache.cs
+++ b/server/src/Flaro.Metadata/ImageCache.cs
@@ -31,6 +31,52 @@ public sealed class ImageCache(
/// Mehr als das ist kein Plakat, sondern ein Versehen.
private const long HöchstensBytes = 16 * 1024 * 1024;
+ /// Legt ein hochgeladenes Bild ab.
+ ///
+ ///
+ /// Derselbe Ort wie für geholte Bilder. 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.
+ ///
+ public async Task 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 StoreAsync(RemoteImage bild, CancellationToken ct = default)
{
var schlüssel = Schlüssel(bild.Url);
diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs
index ab55646..ca421ef 100644
--- a/server/src/Flaro.Metadata/TmdbClient.cs
+++ b/server/src/Flaro.Metadata/TmdbClient.cs
@@ -352,6 +352,54 @@ public sealed class TmdbClient(
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
/// um Bildqualität geht und nicht mehr um die Sprache.
///
+ /// Alle Bilder zu einem Titel — für die Auswahl von Hand.
+ ///
+ ///
+ /// Die Liste kommt ohnehin mit. append_to_response=…,images
+ /// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
+ /// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
+ /// Anfrage.
+ ///
+ public async Task> 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(
+ $"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
+ einstellung, ct);
+
+ if (antwort is null) return [];
+
+ var liste = new List();
+ Sammeln(antwort.Posters, "poster", PosterSize);
+ Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
+ Sammeln(antwort.Logos, "logo", "w500");
+ return liste;
+
+ void Sammeln(List? 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? Posters,
+ [property: JsonPropertyName("backdrops")] List? Backdrops,
+ [property: JsonPropertyName("logos")] List? Logos);
+
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
{
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 TmdbImages(
- [property: JsonPropertyName("logos")] List? Logos);
+ [property: JsonPropertyName("logos")] List? Logos,
+ [property: JsonPropertyName("posters")] List? Posters = null,
+ [property: JsonPropertyName("backdrops")] List? Backdrops = null);
private sealed record TmdbImage(
[property: JsonPropertyName("file_path")] string? FilePath,
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 8263cea..e41daa8 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -169,7 +169,12 @@ export class ApiFehler extends Error {
async function anfrage(pfad: string, opts: RequestInit = {}): Promise {
const kopf = new Headers(opts.headers);
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 });
@@ -479,6 +484,38 @@ export const uebersetzungenHolen = (language: string) =>
export const aehnliche = (id: string) =>
anfrage>(`/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(`/${art}/${id}/artwork`);
+
+export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
+ anfrage(`/${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 };
/** Welche Tage protokolliert sind, neueste zuerst. */
diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts
new file mode 100644
index 0000000..eaf439d
--- /dev/null
+++ b/web/app/src/bilder.ts
@@ -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 = '';
+
+ 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 {
+ 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;
+ }
+}
diff --git a/web/app/src/card.ts b/web/app/src/card.ts
index dde51d2..0ad4026 100644
--- a/web/app/src/card.ts
+++ b/web/app/src/card.ts
@@ -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 { icons } from './icons';
+import { kontextmenue, type Menuepunkt } from './menue';
+import { bilddialog } from './bilder';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
@@ -59,6 +64,11 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string)
knopf.className = 'kachel';
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');
rahmen.className = 'plakat';
rahmen.setAttribute('role', 'img');
@@ -172,3 +182,82 @@ export function reihe(
bereich.append(kopf, streifen);
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);
+}
diff --git a/web/app/src/menue.ts b/web/app/src/menue.ts
new file mode 100644
index 0000000..331ca90
--- /dev/null
+++ b/web/app/src/menue.ts
@@ -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;
+};
+
+/** 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('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 });
diff --git a/web/app/src/style.css b/web/app/src/style.css
index 2ea45d9..7c24c7b 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -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-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; }