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; }