Vier Fehler, und bei zweien log der Kommentar daneben

**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.

Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.

**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.

Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.

**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.

Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.

**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.

Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.

676 Tests grün, davon zehn neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 17:46:46 +02:00
co-authored by Claude Opus 5
parent c6df9fdab0
commit 2809821c9b
9 changed files with 391 additions and 25 deletions
@@ -81,13 +81,38 @@ public static class EinladungEndpoints
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
//
// **Und mit `purge=true` doch.** Die Begründung oben bleibt richtig,
// sie gilt nur nicht ewig: eine Liste, aus der nichts je verschwindet,
// wird irgendwann unlesbar, und dann sieht man die eine Zeile nicht
// mehr, um die es ging. Weggeworfen wird deshalb nur, was ohnehin
// keine Tür mehr öffnet — zurückgezogen, abgelaufen oder
// aufgebraucht. Ein gültiger Link lässt sich nicht wegwischen; erst
// zurückziehen, dann entfernen, und das ist ein Handgriff mehr mit
// Absicht.
verwalten.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct,
bool purge = false) =>
{
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
einladung.RevokedAt ??= uhr.GetUtcNow();
var jetzt = uhr.GetUtcNow();
if (purge)
{
if (einladung.Gilt(jetzt))
{
return Einwand.Bitte(
"Dieser Einladungslink gilt noch. Zieh ihn erst zurück, dann lässt er sich entfernen.");
}
db.Einladungen.Remove(einladung);
await db.SaveChangesAsync(ct);
return Results.NoContent();
}
einladung.RevokedAt ??= jetzt;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
+71 -11
View File
@@ -137,8 +137,14 @@ public sealed class TmdbClient(
// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen.
if (film is null or { Id: 0 }) return null;
return Als(film, "") with
var grund = Als(film, "");
var (plakat, hintergrund) = Bildwahl(
film.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
return grund with
{
Poster = plakat,
Backdrop = hintergrund,
ImdbId = Leer(film.ExternalIds?.ImdbId),
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
Rating = film.VoteAverage > 0 ? film.VoteAverage : null,
@@ -207,8 +213,14 @@ public sealed class TmdbClient(
if (serie is null or { Id: 0 }) return null;
return Als(serie, "") with
var grund = Als(serie, "");
var (plakat, hintergrund) = Bildwahl(
serie.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
return grund with
{
Poster = plakat,
Backdrop = hintergrund,
ImdbId = Leer(serie.ExternalIds?.ImdbId),
// Die übliche Länge einer Folge, nicht die der ganzen Serie.
RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0),
@@ -425,8 +437,12 @@ public sealed class TmdbClient(
void Sammeln(List<TmdbImage>? bilder, string art, string groesse)
{
// **Erst die Sprache, dann die Stimmen.** Sonst steht oben, was
// der Welt gefällt, und nicht, was hierher passt — und wer von
// Hand wählt, scrollt an zwanzig englischen Plakaten vorbei.
foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath))
.OrderByDescending(b => b.VoteAverage ?? 0)
.OrderBy(b => Sprachrang(b.Language, sprache))
.ThenByDescending(b => b.VoteAverage ?? 0)
.Take(24))
{
liste.Add(new Bildkandidat(
@@ -443,22 +459,66 @@ public sealed class TmdbClient(
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
/// <summary>Wie gut ein Bild zur eingestellten Sprache passt — kleiner ist besser.</summary>
///
/// <remarks>
/// <para>
/// Eingestellte Sprache, dann sprachlos, dann Englisch, dann der Rest.
/// </para>
/// <para>
/// <b>Sprachlos vor Englisch, und das ist keine Verlegenheit.</b> Ein
/// Bild ohne Sprachkennung trägt keinen Text — ein Plakat ohne Titel,
/// ein Hintergrund ohne eingebrannten Schriftzug. Das ist für einen
/// deutschen Bestand besser als dasselbe Bild mit englischem Titel
/// darüber.
/// </para>
/// </remarks>
private static int Sprachrang(string? sprache, string gewuenscht) =>
string.Equals(sprache, gewuenscht, StringComparison.OrdinalIgnoreCase) ? 0
: string.IsNullOrEmpty(sprache) ? 1
: string.Equals(sprache, "en", StringComparison.OrdinalIgnoreCase) ? 2
: 3;
/// <summary>Das passendste Bild einer Liste — oder <c>null</c>, wenn sie leer ist.</summary>
private static RemoteImage? Bestes(List<TmdbImage>? bilder, string sprache, string groesse)
{
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
var alle = bilder?.Where(b => !string.IsNullOrWhiteSpace(b.FilePath)).ToList();
if (alle is not { Count: > 0 }) return null;
var beste = alle
.OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0
: string.IsNullOrEmpty(l.Language) ? 1
: string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2
: 3)
.ThenByDescending(l => l.VoteAverage ?? 0)
.OrderBy(b => Sprachrang(b.Language, sprache))
.ThenByDescending(b => b.VoteAverage ?? 0)
.First();
return Bild(beste.FilePath, LogoSize);
return Bild(beste.FilePath, groesse);
}
/// <summary>Plakat und Hintergrund in der Sprache, die eingestellt ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Fehler, den das behebt.</b> Für den Schriftzug gab es diese
/// Wahl längst; für Plakat und Hintergrund wurde das blanke
/// <c>poster_path</c> der Hauptantwort genommen. Das ist bei TMDB das
/// Bild der Originalsprache, wenn in der gewünschten keines eingetragen
/// ist — gemeldet an „365 Tage", das mit polnischem Plakat dastand,
/// obwohl sprachneutrale Plakate vorlagen.
/// </para>
/// <para>
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
/// liefert sie beim selben Abruf; sie zu benutzen kostet keine Anfrage.
/// Fehlt sie — ein alter Zwischenspeicher, eine abgespeckte Antwort —,
/// bleibt es beim Feld der Hauptantwort.
/// </para>
/// </remarks>
private static (RemoteImage? Plakat, RemoteImage? Hintergrund) Bildwahl(
TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund) =>
(Bestes(bilder?.Posters, sprache, PosterSize) ?? rueckfallPlakat,
Bestes(bilder?.Backdrops, sprache, BackdropSize) ?? rueckfallHintergrund);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) =>
Bestes(bilder?.Logos, sprache, LogoSize);
private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?