diff --git a/Flaro/Session/HeroArtwork.swift b/Flaro/Session/HeroArtwork.swift index 0f75e99..fae0d65 100644 --- a/Flaro/Session/HeroArtwork.swift +++ b/Flaro/Session/HeroArtwork.swift @@ -101,7 +101,10 @@ enum HeroArtwork { ? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art] // Quer: das eigene Hintergrundbild, dann das der Staffel, dann // das der Serie — und erst danach das Vorschaubild der Folge. - : [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb] + // **Das Aufmacherbild zuerst.** Es ist das, was für genau diese + // Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe + // dahinter. + : [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb] var gesehen = Set() return pfade diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index e3e2db1..b444800 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -95,6 +95,8 @@ enum Wire { let overview: String? let posterImage: String? let backdropImage: String? + /// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. + let heroImage: String? let files: [File] let state: ItemState? /// Ab wann abspielbar. In der Zukunft heisst: noch nicht. @@ -108,6 +110,8 @@ enum Wire { let overview: String? let posterImage: String? let backdropImage: String? + /// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. + let heroImage: String? let episodeCount: Int let seasons: [Int] let unwatchedCount: Int @@ -154,6 +158,8 @@ enum Wire { let year: Int? let posterImage: String? let backdropImage: String? + /// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. + let heroImage: String? let state: ItemState /// Zu welcher Serie die Folge gehört. let seriesId: String? diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift index 087a072..555a3a0 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift @@ -16,6 +16,7 @@ extension Wire.Movie { duration: files.first?.durationSeconds.map { Int($0 * 1000) }, thumb: posterImage, art: backdropImage, + heroArt: heroImage, playCount: state?.playCount, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, lastPlayedAt: state?.lastPlayedAt, @@ -37,6 +38,7 @@ extension Wire.Series { year: year, thumb: posterImage, art: backdropImage, + heroArt: heroImage, // **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was // offen ist — die App zeigt daraus die Plakette, rechnet aber // intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege @@ -107,6 +109,7 @@ extension Wire.Started { // Serie ein Bild ausgesucht hatte, fand dort ein anderes vor. art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage, seriesThumb: folge ? posterImage : nil, + heroArt: heroImage, index: episode, parentIndex: season, seriesTitle: folge ? title : nil, diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift index 29981f0..cd0d355 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift @@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { /// die Serie, und so heißt es jetzt auch. public let seriesThumb: String? public let seriesArt: String? + /// Das Bild, das jetzt im Aufmacher gilt. + /// + /// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere + /// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und + /// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das + /// Hintergrundbild. + public let heroArt: String? // Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die // Staffel. @@ -126,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { parentThumb: String? = nil, seriesThumb: String? = nil, seriesArt: String? = nil, + heroArt: String? = nil, index: Int? = nil, parentIndex: Int? = nil, parentTitle: String? = nil, @@ -166,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { self.parentThumb = parentThumb self.seriesThumb = seriesThumb self.seriesArt = seriesArt + self.heroArt = heroArt self.index = index self.parentIndex = parentIndex self.parentTitle = parentTitle diff --git a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs index 17bba2b..4b4ae48 100644 --- a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Flaro.Core.Media; using Flaro.Core.Metadata; using Flaro.Data; using Flaro.Metadata; @@ -43,13 +44,20 @@ public static class ArtworkEndpoints ? 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 + var roh = 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) + .Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage, + s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder }) + .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); + .Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage, + m.PosterPinned, m.BackdropPinned, m.LogoPinned, m.Aufmacherbilder }) + .FirstOrDefaultAsync(ct); + + var aktuell = roh is null ? null : new Bildstand( + roh.PosterImage, roh.BackdropImage, roh.LogoImage, + roh.PosterPinned, roh.BackdropPinned, roh.LogoPinned, + Aufmacherwahl.Lesen(roh.Aufmacherbilder)); if (aktuell is null) return Results.NotFound(); @@ -104,6 +112,53 @@ public static class ArtworkEndpoints return gesetzt ? Results.NoContent() : Results.NotFound(); }); + // **Die Mehrfachauswahl fürs Karussell.** + // + // Poster, Hintergrund und Schriftzug bleiben einzeln: sie stehen + // hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. Der + // Aufmacher dagegen ist eine Bühne — wer fünf Aufnahmen ausgesucht + // hat, soll sie auch zu sehen bekommen. + gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork/aufmacher", async ( + string art, Guid id, AufmacherWahlRequest anfrage, + FlaroContext db, IImageStore ablage, CancellationToken ct) => + { + if (anfrage.Urls.Count > Aufmacherwahl.Hoechstens) + return Einwand.Bitte($"Höchstens {Aufmacherwahl.Hoechstens} Bilder."); + + // Erst alle in den Zwischenspeicher, dann setzen: sonst zeigte + // die Auswahl auf etwas, das es hier nicht gibt. Was sich nicht + // laden lässt, fällt heraus — ein einzelnes totes Bild soll + // nicht die ganze Auswahl verhindern. + var schluessel = new List(); + foreach (var url in anfrage.Urls.Take(Aufmacherwahl.Hoechstens)) + { + if (string.IsNullOrWhiteSpace(url)) continue; + + // **Was schon hier liegt, bleibt liegen.** Der Client + // schickt eine Mischung: die bereits gewählten Bilder als + // Zwischenspeicherschlüssel und die neuen als Adresse. Einen + // Schlüssel noch einmal herunterladen zu wollen hiesse, eine + // Adresse daraus zu raten, die es nicht gibt. + if (!url.Contains("://", StringComparison.Ordinal)) + { + schluessel.Add(url); + continue; + } + + if (await ablage.StoreAsync(new RemoteImage(url), ct) is { } k) schluessel.Add(k); + } + + var zeile = Aufmacherwahl.Schreiben(schluessel); + + var betroffen = art == "series" + ? await db.Series.Where(s => s.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct) + : await db.Movies.Where(m => m.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct); + + return betroffen > 0 ? 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) => diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 5ae0ab5..3992a1d 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -224,13 +224,23 @@ 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); + bool PosterPinned, bool BackdropPinned, bool LogoPinned, + /// Die für den Aufmacher gewählten Bilder, höchstens fünf. + IReadOnlyList? Heroes = null); public sealed record Bildauswahl( Bildstand Current, IReadOnlyList Candidates); public sealed record BildWahlRequest(string Kind, string? Url); +/// Die Mehrfachauswahl fürs Aufmacher-Karussell. +/// +/// +/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt, +/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück. +/// +public sealed record AufmacherWahlRequest(IReadOnlyList Urls); + /// Ein Titel, der noch wartet oder geplant ist. /// /// @@ -455,7 +465,17 @@ public sealed record MovieResponse( /// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu /// erfahren, wohin dieser eine Film gehört. /// - Guid? LibraryId = null); + Guid? LibraryId = null, + /// Das Bild, das jetzt im Aufmacher gilt. + /// + /// + /// Vom Server gewählt, nicht vom Client. Wer mehrere Aufnahmen + /// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar + /// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe + /// Aufmacherwahl. null heisst „keine ausgesucht"; dann + /// gilt wie bisher der Hintergrund. + /// + string? HeroImage = null); public sealed record CreditResponse( Guid Id, string Name, string Role, string? Character, string? Image); @@ -536,7 +556,17 @@ public sealed record SeriesResponse( /// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu /// erfahren, wohin dieser eine Film gehört. /// - Guid? LibraryId = null); + Guid? LibraryId = null, + /// Das Bild, das jetzt im Aufmacher gilt. + /// + /// + /// Vom Server gewählt, nicht vom Client. Wer mehrere Aufnahmen + /// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar + /// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe + /// Aufmacherwahl. null heisst „keine ausgesucht"; dann + /// gilt wie bisher der Hintergrund. + /// + string? HeroImage = null); /// Ein überspringbarer Abschnitt — Vorspann oder Abspann. /// @@ -883,7 +913,10 @@ public sealed record ContinueEntry( /// Seite und will das Bild der Serie; dort stand bisher das Folgenbild, /// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor. /// - string? SeriesBackdropImage = null); + string? SeriesBackdropImage = null, + + /// Das Bild, das jetzt im Aufmacher gilt — siehe Aufmacherwahl. + string? HeroImage = null); /// Was jemand geschaut hat — nur für ihn selbst. /// diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 81411b1..d1f48f3 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -535,6 +535,7 @@ public static class LibraryContentEndpoints // Korrektur stand „WALL·E" weiter unter E, bis man die // Seite neu lud. m.SortTitleValue, m.TitlePinned, m.SortTitlePinned, + m.Aufmacherbilder, }) .ToListAsync(); @@ -559,7 +560,9 @@ public static class LibraryContentEndpoints PublishAt: m.PublishAt, TitlePinned: m.TitlePinned, SortTitle: m.SortTitleValue, - SortTitlePinned: m.SortTitlePinned)).ToList(); + SortTitlePinned: m.SortTitlePinned, + HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow))) + .ToList(); return Results.Ok(new Page(items, gesamt)); }).RequireAuthorization(); @@ -615,6 +618,7 @@ public static class LibraryContentEndpoints PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id) .Min(f => f.PublishAt), s.SortTitleValue, s.TitlePinned, s.SortTitlePinned, + s.Aufmacherbilder, }) .ToListAsync(); @@ -643,7 +647,8 @@ public static class LibraryContentEndpoints State: stände.GetValueOrDefault(s.Id), TitlePinned: s.TitlePinned, SortTitle: s.SortTitleValue, - SortTitlePinned: s.SortTitlePinned); + SortTitlePinned: s.SortTitlePinned, + HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow)); }).ToList(); return Results.Ok(new Page(items, gesamt)); @@ -750,7 +755,8 @@ public static class LibraryContentEndpoints SortTitlePinned: film.SortTitlePinned, PublishAt: film.PublishAt, ExternalRatings: await FremdwertungenZu(db, film: id, serie: null), - LibraryId: film.LibraryId)); + LibraryId: film.LibraryId, + HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow))); }).RequireAuthorization(); app.MapGet("/api/v1/series/{id:guid}", async ( @@ -787,7 +793,8 @@ public static class LibraryContentEndpoints SortTitlePinned: serie.SortTitlePinned, PublishAt: folgen.Min(f => f.PublishAt), ExternalRatings: await FremdwertungenZu(db, film: null, serie: id), - LibraryId: serie.LibraryId)); + LibraryId: serie.LibraryId, + HeroImage: Aufmacherwahl.Jetziges(serie.Aufmacherbilder, id, DateTimeOffset.UtcNow))); }).RequireAuthorization(); // **Eine Folge für sich.** diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index c103490..e2b85e4 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -1,5 +1,7 @@ using System.Security.Claims; using Flaro.Api.Auth; +using Flaro.Core.Library; +using Flaro.Core.Media; using Flaro.Core.Playback; using Flaro.Data; using Microsoft.EntityFrameworkCore; @@ -96,7 +98,18 @@ public static class PlaybackEndpoints var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds); if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position; - var ergebnis = ProgressRule.Apply(gemeldet, laenge, regeln); + // **Der Abspann, wenn er erkannt wurde.** Er zieht die Schwelle + // fürs Häkchen nach vorn — siehe `ProgressRule.Apply`. + var abspannAb = zustand.EpisodeId is { } folge + ? await db.Marken.AsNoTracking() + .Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann) + .Select(m => (double?)m.VonSekunden) + .FirstOrDefaultAsync() + : null; + + var ergebnis = ProgressRule.Apply( + gemeldet, laenge, regeln, + abspannAb is { } sek ? TimeSpan.FromSeconds(sek) : null); // **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus // der alten Stelle; eine Zeile später gibt es die nicht mehr. @@ -610,7 +623,7 @@ public static class PlaybackEndpoints var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id)) - .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt }) + .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt, m.Aufmacherbilder }) .ToDictionaryAsync(m => m.Id); var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id)) @@ -618,7 +631,7 @@ public static class PlaybackEndpoints { f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt, - SerienBild = s.BackdropImage, + SerienBild = s.BackdropImage, SerienAufmacher = s.Aufmacherbilder, }) .ToDictionaryAsync(f => f.Id); @@ -633,7 +646,8 @@ public static class PlaybackEndpoints m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z), FileId: dateien.GetValueOrDefault(m.Id).Id, DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden, - PublishAt: m.PublishAt)); + PublishAt: m.PublishAt, + HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow))); else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) liste.Add(new ContinueEntry( f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, @@ -642,7 +656,12 @@ public static class PlaybackEndpoints FileId: dateien.GetValueOrDefault(f.Id).Id, DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden, PublishAt: f.PublishAt, - SeriesBackdropImage: f.SerienBild)); + SeriesBackdropImage: f.SerienBild, + // **Das der Serie, nicht das der Folge.** Der Aufmacher + // zeigt, worum es geht; eine einzelne Folge hat dafür + // selten ein eigenes Bild und nie fünf. + HeroImage: Aufmacherwahl.Jetziges( + f.SerienAufmacher, f.SerienId, DateTimeOffset.UtcNow))); } return liste; } diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index 194b285..d00b9f5 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -302,6 +302,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert /// public string? LogoImage { get; set; } + /// Weitere Bilder für den Aufmacher, durch Komma getrennt. + /// + /// + /// + /// Warum nicht eine Tabelle. Es sind höchstens fünf + /// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen + /// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine + /// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für + /// eine Liste, die in eine Zeile passt. + /// + /// + /// Getrennt vom Hintergrund. Der bleibt, was er ist: das eine + /// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen + /// Aufnahmen für das Karussell, und nur die wechseln. + /// + /// + public string? Aufmacherbilder { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } @@ -497,6 +515,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert /// public string? LogoImage { get; set; } + /// Weitere Bilder für den Aufmacher, durch Komma getrennt. + /// + /// + /// + /// Warum nicht eine Tabelle. Es sind höchstens fünf + /// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen + /// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine + /// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für + /// eine Liste, die in eine Zeile passt. + /// + /// + /// Getrennt vom Hintergrund. Der bleibt, was er ist: das eine + /// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen + /// Aufnahmen für das Karussell, und nur die wechseln. + /// + /// + public string? Aufmacherbilder { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } diff --git a/server/src/Flaro.Core/Media/Aufmacherwahl.cs b/server/src/Flaro.Core/Media/Aufmacherwahl.cs new file mode 100644 index 0000000..b306e37 --- /dev/null +++ b/server/src/Flaro.Core/Media/Aufmacherwahl.cs @@ -0,0 +1,75 @@ +namespace Flaro.Core.Media; + +/// Welches der gewählten Aufmacherbilder gerade an der Reihe ist. +/// +/// +/// +/// Warum es wechselt. Ein Karussell, das jedes Mal dieselben fünf +/// Bilder zeigt, ist nach einer Woche ein Standbild. Wer zu einem Titel +/// mehrere Aufnahmen ausgesucht hat, soll sie auch zu sehen bekommen. +/// +/// +/// Aus der Uhr gerechnet, nicht gewürfelt und nicht gespeichert. Ein +/// Zufall je Abruf hiesse, dass das Bild beim Blättern springt und beim +/// Zurückkommen ein drittes ist. Eine gespeicherte Wahl hiesse, einen +/// Schreibvorgang je Titel und Fenster. Aus Zeit und Kennung gerechnet ist +/// beides nicht nötig: innerhalb eines Fensters kommt überall dieselbe +/// Antwort heraus — im Web, in der App, nach einem Neustart. +/// +/// +/// Die Kennung geht mit in die Rechnung, damit nicht alle Titel im +/// selben Augenblick umschalten. Sonst sähe eine Startseite alle fünf +/// Stunden vollständig anders aus statt nach und nach. +/// +/// +public static class Aufmacherwahl +{ + /// Wie lange ein Bild stehen bleibt. + public static readonly TimeSpan Fenster = TimeSpan.FromHours(5); + + /// Wie viele Bilder je Titel höchstens gewählt werden dürfen. + /// + /// + /// Fünf. Mehr hiesse, dass ein einzelnes kaum noch vorkommt: bei zehn + /// Bildern und fünf Stunden Fenster sähe man dasselbe zweimal im Monat. + /// + public const int Hoechstens = 5; + + /// Zerlegt die gespeicherte Zeile in die einzelnen Schlüssel. + public static IReadOnlyList Lesen(string? gespeichert) => + string.IsNullOrWhiteSpace(gespeichert) + ? [] + : gespeichert.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + /// Setzt die Zeile zusammen — gekürzt und ohne Doppelte. + public static string? Schreiben(IEnumerable schluessel) + { + var sauber = schluessel + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + // Ein Komma im Schlüssel zerlegte die Zeile beim Lesen in zwei. + .Where(s => !s.Contains(',')) + .Distinct(StringComparer.Ordinal) + .Take(Hoechstens) + .ToList(); + + return sauber.Count == 0 ? null : string.Join(',', sauber); + } + + /// Das Bild, das jetzt gilt — oder null, wenn keines gewählt ist. + public static string? Jetziges(string? gespeichert, Guid titel, DateTimeOffset jetzt) + { + var bilder = Lesen(gespeichert); + if (bilder.Count == 0) return null; + if (bilder.Count == 1) return bilder[0]; + + var fenster = jetzt.ToUnixTimeSeconds() / (long)Fenster.TotalSeconds; + // Die ersten vier Bytes der Kennung als Versatz. **Nicht + // `GetHashCode`**: der ist je Programmlauf anders gestreut, und das + // Bild spränge bei jedem Neustart des Servers. + var versatz = BitConverter.ToUInt32(titel.ToByteArray(), 0); + + var stelle = (long)((ulong)fenster + versatz) % bilder.Count; + return bilder[(int)stelle]; + } +} diff --git a/server/src/Flaro.Core/Playback/ProgressRule.cs b/server/src/Flaro.Core/Playback/ProgressRule.cs index b376453..97270fc 100644 --- a/server/src/Flaro.Core/Playback/ProgressRule.cs +++ b/server/src/Flaro.Core/Playback/ProgressRule.cs @@ -50,7 +50,21 @@ public static class ProgressRule /// es zu merken. /// /// Ab welchem Anteil ein Titel als gesehen gilt. - public static Outcome Apply(TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln) + /// + /// Wann der Abspann anfängt, falls er erkannt wurde. + /// + /// Dann zählt er, nicht der Anteil. Manche Serie hängt sechs + /// Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent + /// liegen dann mitten im Abspann, und die Folge gilt erst als gesehen, + /// wenn man ihn sich ganz ansieht. Die Schwelle dafür herunterzudrehen + /// hiesse, jeden anderen Titel zu früh abzuhaken. Der Abspann ist die + /// genauere Antwort auf dieselbe Frage: hier ist die Handlung vorbei. + /// + /// Der Anteil bleibt daneben stehen und gilt weiter — wer über ihn + /// hinauskommt, ohne dass ein Abspann erkannt wäre, ist genauso fertig. + /// + public static Outcome Apply( + TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln, TimeSpan? abspannAb = null) { // Eine negative Stelle gibt es nicht, und eine jenseits des Endes // auch nicht. Beides kommt vor: der eine Player meldet beim Spulen @@ -62,7 +76,14 @@ public static class ProgressRule if (stelle > laenge) stelle = laenge; - return new Outcome(stelle, Watched: stelle >= laenge * regeln.GesehenAnteil); + var schwelle = laenge * regeln.GesehenAnteil; + + // Der Abspann zieht die Schwelle nach vorn, nie nach hinten: eine + // falsch erkannte Marke bei Minute vierzig darf nicht dazu führen, + // dass eine Folge erst nach der Marke als gesehen gilt. + if (abspannAb is { } ab && ab > TimeSpan.Zero && ab < schwelle) schwelle = ab; + + return new Outcome(stelle, Watched: stelle >= schwelle); } /// Ob ein Zustand in „Weiterschauen" gehört. diff --git a/server/src/Flaro.Data/Migrations/20261012193653_Aufmacherbilder.Designer.cs b/server/src/Flaro.Data/Migrations/20261012193653_Aufmacherbilder.Designer.cs new file mode 100644 index 0000000..e0a2c5b --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261012193653_Aufmacherbilder.Designer.cs @@ -0,0 +1,1299 @@ +// +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("20261012193653_Aufmacherbilder")] + partial class Aufmacherbilder + { + /// + 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.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + 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.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + 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("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + 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.Fremdbewertung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anzeige") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Quelle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Wert") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("MovieId", "Quelle") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Quelle") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Fremdbewertungen"); + }); + + 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.Marke", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .HasColumnType("INTEGER"); + + b.Property("BisSekunden") + .HasColumnType("REAL"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErkanntAm") + .HasColumnType("INTEGER"); + + b.Property("VonSekunden") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Art") + .IsUnique(); + + b.ToTable("Marken"); + }); + + 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.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("Aufmacherbilder") + .HasColumnType("TEXT"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + + 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("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .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("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + 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("Aufmacherbilder") + .HasColumnType("TEXT"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .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("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .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("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwort", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stichworte"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StichwortId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("StichwortId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("StichwortId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Stichwortlinks"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + 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("SeriesId") + .HasColumnType("TEXT"); + + 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("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" 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("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .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.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .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.Fremdbewertung", b => + { + 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.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.Marke", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Meldung", 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.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .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.Library.Stichwortlink", b => + { + 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); + + b.HasOne("Flaro.Core.Library.Stichwort", null) + .WithMany() + .HasForeignKey("StichwortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", 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(); + }); + + 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.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .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/20261012193653_Aufmacherbilder.cs b/server/src/Flaro.Data/Migrations/20261012193653_Aufmacherbilder.cs new file mode 100644 index 0000000..72e501e --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261012193653_Aufmacherbilder.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Aufmacherbilder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Aufmacherbilder", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Aufmacherbilder", + table: "Movies", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Aufmacherbilder", + table: "Series"); + + migrationBuilder.DropColumn( + name: "Aufmacherbilder", + table: "Movies"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index c2c3e26..e94e37b 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -615,6 +615,9 @@ namespace Flaro.Data.Migrations b.Property("AddedAt") .HasColumnType("INTEGER"); + b.Property("Aufmacherbilder") + .HasColumnType("TEXT"); + b.Property("BackdropImage") .HasColumnType("TEXT"); @@ -753,6 +756,9 @@ namespace Flaro.Data.Migrations b.Property("AddedAt") .HasColumnType("INTEGER"); + b.Property("Aufmacherbilder") + .HasColumnType("TEXT"); + b.Property("BackdropImage") .HasColumnType("TEXT"); diff --git a/server/tests/Flaro.Core.Tests/AufmacherwahlTests.cs b/server/tests/Flaro.Core.Tests/AufmacherwahlTests.cs new file mode 100644 index 0000000..015df50 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/AufmacherwahlTests.cs @@ -0,0 +1,100 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Welches Aufmacherbild gerade gilt. +public class AufmacherwahlTests +{ + private static readonly Guid Titel = Guid.Parse("11111111-2222-3333-4444-555555555555"); + private static readonly DateTimeOffset Montag = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero); + + [Test] + public void Ohne_Auswahl_gibt_es_keines() + { + Assert.That(Aufmacherwahl.Jetziges(null, Titel, Montag), Is.Null); + Assert.That(Aufmacherwahl.Jetziges("", Titel, Montag), Is.Null); + } + + [Test] + public void Ein_einziges_Bild_bleibt_immer_dasselbe() + { + var spaeter = Montag.AddDays(30); + Assert.That(Aufmacherwahl.Jetziges("a", Titel, Montag), Is.EqualTo("a")); + Assert.That(Aufmacherwahl.Jetziges("a", Titel, spaeter), Is.EqualTo("a")); + } + + /// Innerhalb eines Fensters kommt überall dasselbe heraus. + /// + /// + /// Der eigentliche Grund für die Rechnung. Ein Zufall je Abruf + /// hiesse: beim Blättern springt das Bild, und die App zeigt ein + /// anderes als das Web. + /// + [Test] + public void Innerhalb_des_Fensters_wechselt_nichts() + { + var erstes = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag); + var kurzDarauf = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag.AddMinutes(90)); + + Assert.That(kurzDarauf, Is.EqualTo(erstes)); + } + + [Test] + public void Nach_dem_Fenster_kommt_ein_anderes() + { + var erstes = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag); + var danach = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag + Aufmacherwahl.Fenster); + + Assert.That(danach, Is.Not.EqualTo(erstes)); + } + + /// Über einen Tag kommt jedes Bild vor. + [Test] + public void Alle_Bilder_kommen_an_die_Reihe() + { + var gesehen = new HashSet(); + for (var i = 0; i < 24; i++) + gesehen.Add(Aufmacherwahl.Jetziges("a,b,c", Titel, Montag.AddHours(i * 5))); + + Assert.That(gesehen, Is.EquivalentTo(new[] { "a", "b", "c" })); + } + + /// Zwei Titel schalten nicht im selben Augenblick um. + [Test] + public void Nicht_alle_Titel_zugleich() + { + var anderer = Guid.Parse("99999999-8888-7777-6666-555555555555"); + var eins = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag); + var zwei = Aufmacherwahl.Jetziges("a,b,c", anderer, Montag); + + Assert.That(zwei, Is.Not.EqualTo(eins)); + } + + [Test] + public void Mehr_als_fuenf_werden_abgeschnitten() + { + var zeile = Aufmacherwahl.Schreiben(["a", "b", "c", "d", "e", "f", "g"]); + Assert.That(Aufmacherwahl.Lesen(zeile), Has.Count.EqualTo(5)); + } + + [Test] + public void Doppelte_und_Leere_fallen_heraus() + { + var zeile = Aufmacherwahl.Schreiben(["a", " a ", "", " ", "b"]); + Assert.That(Aufmacherwahl.Lesen(zeile), Is.EqualTo(new[] { "a", "b" })); + } + + /// Ein Komma im Schlüssel zerlegte die Zeile beim Lesen. + [Test] + public void Ein_Schluessel_mit_Komma_wird_verworfen() + { + Assert.That(Aufmacherwahl.Lesen(Aufmacherwahl.Schreiben(["a,b", "c"])), + Is.EqualTo(new[] { "c" })); + } + + [Test] + public void Ohne_Bilder_bleibt_die_Zeile_leer() + { + Assert.That(Aufmacherwahl.Schreiben([]), Is.Null); + } +} diff --git a/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs index 867d67b..da7756d 100644 --- a/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs +++ b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs @@ -268,4 +268,60 @@ public sealed class ProgressRuleTests private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) => new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge }; + + /// Ein erkannter Abspann zieht das Häkchen nach vorn. + /// + /// + /// Der Fall aus dem Gebrauch. Eine Serie hängt sechs Minuten + /// Abspann an eine Folge von fünfundvierzig. Neunzig Prozent liegen dann + /// mitten im Abspann, und die Folge gilt erst als gesehen, wenn man ihn + /// sich ganz ansieht. Die Schwelle dafür herunterzudrehen hiesse, jeden + /// anderen Titel zu früh abzuhaken. + /// + [Test] + public void Der_Abspann_zaehlt_als_Schluss() + { + var laenge = TimeSpan.FromMinutes(45); + var abspann = TimeSpan.FromMinutes(39); + var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 }; + + var amAbspann = ProgressRule.Apply(abspann, laenge, regeln, abspann); + var davor = ProgressRule.Apply(abspann - TimeSpan.FromSeconds(30), laenge, regeln, abspann); + + Assert.Multiple(() => + { + Assert.That(amAbspann.Watched, Is.True, "ab dem Abspann ist die Handlung vorbei"); + Assert.That(davor.Watched, Is.False, "eine halbe Minute davor noch nicht"); + }); + } + + /// Eine Marke hinter der Schwelle verschiebt nichts nach hinten. + /// + /// + /// Sonst machte eine falsch erkannte Marke aus einem Titel, der bei + /// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig + /// ist — und niemand sähe, warum. + /// + [Test] + public void Eine_spaete_Marke_schiebt_nichts_nach_hinten() + { + var laenge = TimeSpan.FromMinutes(45); + var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 }; + + var ergebnis = ProgressRule.Apply( + TimeSpan.FromMinutes(41), laenge, regeln, abspannAb: TimeSpan.FromMinutes(44)); + + Assert.That(ergebnis.Watched, Is.True); + } + + /// Ohne Marke bleibt alles, wie es war. + [Test] + public void Ohne_Abspann_gilt_der_Anteil() + { + var laenge = TimeSpan.FromMinutes(45); + var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 }; + + Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(39), laenge, regeln).Watched, Is.False); + Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(41), laenge, regeln).Watched, Is.True); + } } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index c76acf5..3dad764 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -85,6 +85,8 @@ export type Film = { * * Das Auffrischen hängt an der Mediathek, nicht am Titel. */ libraryId: string | null; + /** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */ + heroImage?: string | null; }; export type Serie = { id: string; title: string; year: number | null; overview: string | null; @@ -112,6 +114,8 @@ export type Serie = { * * Das Auffrischen hängt an der Mediathek, nicht am Titel. */ libraryId: string | null; + /** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */ + heroImage?: string | null; }; /** Ein überspringbarer Abschnitt einer Folge. */ export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number }; @@ -146,6 +150,8 @@ export type Angefangen = { publishAt?: string | null; /** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */ seriesBackdropImage?: string | null; + /** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */ + heroImage?: string | null; }; export type Bibliothek = { id: string; name: string; @@ -897,6 +903,8 @@ export type Bildauswahl = { current: { poster: string | null; backdrop: string | null; logo: string | null; posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean; + /** Die fürs Karussell gewählten Bilder, höchstens fünf. */ + heroes?: string[] | null; }; candidates: Bildkandidat[]; }; @@ -904,6 +912,16 @@ export type Bildauswahl = { export const bildauswahl = (art: 'movies' | 'series', id: string) => anfrage(`/${art}/${id}/artwork`); +/** Setzt die Auswahl fürs Aufmacher-Karussell — alle auf einmal. + * + * Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt, + * sondern die ganze Auswahl ersetzt. Vorhandene gehen als + * Zwischenspeicherschlüssel mit, neue als Adresse — der Server + * unterscheidet das selbst. */ +export const aufmacherSetzen = (art: 'movies' | 'series', id: string, urls: string[]) => + anfrage(`/${art}/${id}/artwork/aufmacher`, + { method: 'PUT', body: JSON.stringify({ urls }) }); + export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) => anfrage(`/${art}/${id}/artwork`, { method: 'PUT', body: JSON.stringify({ kind, url }) }); diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts index a21009d..dc18b99 100644 --- a/web/app/src/bilder.ts +++ b/web/app/src/bilder.ts @@ -1,5 +1,6 @@ import { - bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat, + bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, aufmacherSetzen, + type Bildkandidat, } from './api'; import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util'; import { icons } from './icons'; @@ -8,6 +9,13 @@ import { zuschneiden } from './bildzuschnitt'; type Bildart = 'poster' | 'backdrop' | 'logo'; +/** Wie viele Bilder das Karussell höchstens je Titel durchwechselt. + * + * Muss zu `Aufmacherwahl.Hoechstens` auf dem Server passen. Mehr hiesse, + * dass ein einzelnes kaum noch vorkommt: bei zehn Bildern und fünf Stunden + * Fenster sähe man dasselbe zweimal im Monat. */ +const AUFMACHER_HOECHSTENS = 5; + /** Was jede Bildart ist — und in welcher Form sie hochgeladen wird. * * **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein @@ -93,7 +101,7 @@ export function bilderflaeche( koerper.innerHTML = '
'; /** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */ - let offen: Bildart = 'poster'; + let offen: Bildart | 'aufmacher' = 'poster'; void laden(); @@ -258,6 +266,120 @@ export function bilderflaeche( buehne.appendChild(abschnitt); bloecke.set(bildart, abschnitt); }); + + aufmacherreiter(auswahl, reiter, buehne); + } + + /** Der vierte Reiter: mehrere Bilder fürs Karussell. + * + * **Warum er nicht in der Schleife darüber steht.** Die drei anderen + * setzen genau ein Bild, lassen sich festsetzen, wieder freigeben und von + * der Platte hochladen. Hier geht es um eine Auswahl aus denselben + * Hintergründen — kein Festsetzen, kein Hochladen, dafür mehrere. In + * dieselbe Schleife gezwängt wäre jede zweite Zeile ein `if`. + */ + function aufmacherreiter( + auswahl: Awaited>, + reiter: HTMLElement, buehne: HTMLElement, + ): void { + const gewaehlt = [...(auswahl.current.heroes ?? [])]; + const passende = auswahl.candidates.filter(k => k.kind === 'backdrop'); + + const knopfReiter = document.createElement('button'); + knopfReiter.type = 'button'; + knopfReiter.className = 'bildreiter-knopf' + (offen === 'aufmacher' ? ' an' : ''); + knopfReiter.setAttribute('role', 'tab'); + knopfReiter.setAttribute('aria-selected', String(offen === 'aufmacher')); + knopfReiter.innerHTML = 'Aufmacher' + + `${gewaehlt.length}/${AUFMACHER_HOECHSTENS}`; + knopfReiter.onclick = () => { offen = 'aufmacher'; void laden(); }; + reiter.appendChild(knopfReiter); + + if (offen !== 'aufmacher') return; + + const abschnitt = document.createElement('section'); + abschnitt.className = 'bildblock'; + + const kopfzeile = document.createElement('div'); + kopfzeile.className = 'knopfreihe'; + const u = document.createElement('h3'); + u.textContent = 'Aufmacher'; + const hinweis = document.createElement('p'); + hinweis.className = 'winzig'; + hinweis.textContent = `Bis zu ${AUFMACHER_HOECHSTENS} Bilder. Das Karussell wechselt alle ` + + 'paar Stunden zwischen ihnen — Plakat und Hintergrund bleiben, wie sie sind.'; + kopfzeile.appendChild(u); + abschnitt.append(kopfzeile, hinweis); + + if (passende.length === 0 && gewaehlt.length === 0) { + const leer = document.createElement('p'); + leer.className = 'winzig'; + leer.textContent = 'Nichts zur Auswahl.'; + abschnitt.appendChild(leer); + buehne.appendChild(abschnitt); + return; + } + + const gitter = document.createElement('div'); + gitter.className = 'bildgitter'; + gitter.style.setProperty('--verhaeltnis', '16 / 9'); + + // Die gewählten zuerst, in ihrer Reihenfolge — so sieht man auf einen + // Blick, was das Karussell zeigt. + gewaehlt.forEach(schluessel => gitter.appendChild( + aufmacherkachel(bildAdresse(schluessel)!, true, () => speichern( + gewaehlt.filter(x => x !== schluessel))))); + + passende.forEach(k => gitter.appendChild( + aufmacherkachel(k.thumbUrl, false, () => { + if (gewaehlt.length >= AUFMACHER_HOECHSTENS) { + hinweisZeigen(`Mehr als ${AUFMACHER_HOECHSTENS} gehen nicht. Nimm erst eines weg.`); + return Promise.resolve(); + } + return speichern([...gewaehlt, k.url]); + }))); + + abschnitt.appendChild(gitter); + buehne.appendChild(abschnitt); + + async function speichern(neu: string[]): Promise { + await aufmacherSetzen(art, id, neu); + beiAenderung?.(); + await laden(); + } + } + + /** Eine Kachel im Aufmacher-Reiter — angekreuzt oder nicht. */ + function aufmacherkachel( + adresse: string, drin: boolean, tun: () => Promise, + ): HTMLElement { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'bildwahl' + (drin ? ' an' : ''); + b.title = drin ? 'Aus dem Karussell nehmen' : 'Ins Karussell aufnehmen'; + + const bild = document.createElement('img'); + bild.src = adresse; + bild.alt = ''; + bild.loading = 'lazy'; + b.appendChild(bild); + + if (drin) { + const marke = document.createElement('span'); + marke.className = 'bildwahl-marke'; + marke.innerHTML = icons.haken; + b.appendChild(marke); + } + + b.onclick = async () => { + b.disabled = true; + try { await tun(); } + catch (fehler) { + b.after(fehlerzeile(meldung(fehler))); + b.disabled = false; + } + }; + return b; } function kachelFuer( diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts index 27de86f..95827f9 100644 --- a/web/app/src/hero.ts +++ b/web/app/src/hero.ts @@ -450,7 +450,11 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein id: eintrag.id, titel: eintrag.title, schriftzug: eintrag.logoImage, - hintergrund: eintrag.backdropImage ?? '', + // **Das Aufmacherbild sticht den Hintergrund.** Wer mehrere Aufnahmen + // ausgesucht hat, bekommt hier die, die gerade gilt — der Server + // entscheidet das, damit Web und App dasselbe zeigen. Ohne Auswahl + // bleibt es beim Hintergrund. + hintergrund: eintrag.heroImage ?? eintrag.backdropImage ?? '', jahr: eintrag.year, laufzeit: eintrag.runtimeMinutes, freigabe: eintrag.contentRating, @@ -483,7 +487,7 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag { // einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für // eine Fläche über den halben Bildschirm: wer der Serie ein Bild // ausgesucht hat, fand hier ein anderes vor. - hintergrund: eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '', + hintergrund: eintrag.heroImage ?? eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '', jahr: eintrag.year, laufzeit: null, freigabe: null, diff --git a/web/app/src/style.css b/web/app/src/style.css index ab78012..671fec7 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1499,15 +1499,23 @@ html[data-aufmacher="voll"] { **Kurz und steil, nicht lang und flach.** Hier stand 55 % Deckung auf halber Höhe und durchsichtig erst bei 85 % — von einem starken Aufmacher war dadurch vor allem Grau zu sehen. Der Text sitzt unten links; genau - dort muss es tragen, und darüber nicht. */ + dort muss es tragen, und darüber nicht. + + **Zweite Runde, und zwar nach unten.** Auch kurz und steil war es noch + zu dunkel: der Verlauf von links legte sich bis zur Mitte des Bildes und + nahm ihm die Farbe, und der von unten begann sein Grau schon auf + Zweidritteln. Jetzt trägt der untere das Gewicht — dort steht die + Schrift —, und der von links ist nur noch ein Hauch über dem linken + Drittel. Dass Schrift auf einem hellen Bild lesbar bleibt, übernimmt + ihr eigener Schattenwurf, nicht eine Decke über dem ganzen Aufmacher. */ .hero-buehne::after { content: ''; position: absolute; inset: 0; background: - linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .70) 16%, - rgba(11, 11, 13, .25) 38%, transparent 62%), - linear-gradient(to right, rgba(11, 11, 13, .85) 0%, rgba(11, 11, 13, .30) 30%, - transparent 55%); + linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .62) 12%, + rgba(11, 11, 13, .16) 30%, transparent 48%), + linear-gradient(to right, rgba(11, 11, 13, .55) 0%, rgba(11, 11, 13, .12) 22%, + transparent 38%); } .hero-inhalt { @@ -1537,6 +1545,14 @@ html[data-aufmacher="voll"] { font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em; } +/* **Der Schatten trägt, nicht die Decke.** Seit der Verlauf heller ist, + kann das Bild hinter der Schrift hell sein. Ein Schattenwurf am Text + kostet nichts und wirkt genau dort, wo er gebraucht wird — eine dunklere + Decke über dem ganzen Aufmacher wirkte überall. */ +.hero-titel, +.hero-meta, +.hero-text { text-shadow: 0 1px 4px rgba(0, 0, 0, .85), 0 0 18px rgba(0, 0, 0, .55); } + .hero-meta { font-size: var(--t-normal); color: var(--text-dim); } /* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die