Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein Hintergrundbild, und das Karussell war nach einer Woche ein Standbild. Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter einem vierten Reiter, gespeist aus denselben Hintergründen. Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im selben Augenblick umschalten. Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. **Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten. Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt trägt der Schattenwurf der Schrift, was vorher eine Decke über dem ganzen Bild tat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8cf2118723
commit
1d70be0f99
@@ -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<string>();
|
||||
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) =>
|
||||
|
||||
@@ -224,13 +224,23 @@ public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||
public sealed record Bildstand(
|
||||
string? Poster, string? Backdrop, string? Logo,
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned,
|
||||
/// <summary>Die für den Aufmacher gewählten Bilder, höchstens fünf.</summary>
|
||||
IReadOnlyList<string>? Heroes = null);
|
||||
|
||||
public sealed record Bildauswahl(
|
||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||
|
||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||
|
||||
/// <summary>Die Mehrfachauswahl fürs Aufmacher-Karussell.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||
/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück.
|
||||
/// </remarks>
|
||||
public sealed record AufmacherWahlRequest(IReadOnlyList<string> Urls);
|
||||
|
||||
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null);
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> 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
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
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.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null);
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> 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
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null);
|
||||
|
||||
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
|
||||
///
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
string? SeriesBackdropImage = null);
|
||||
string? SeriesBackdropImage = null,
|
||||
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt — siehe <c>Aufmacherwahl</c>.</summary>
|
||||
string? HeroImage = null);
|
||||
|
||||
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
||||
///
|
||||
|
||||
@@ -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<MovieResponse>(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<SeriesResponse>(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.**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user