Ein Knopf, der nichts tut, ist schlimmer als keiner

Der geplante Film zeigte weiter „Abspielen" und „Herunterladen", und wer
drückte, bekam „Dieser Titel ist ab dem 6.10. um 18 Uhr verfügbar". Die
Sperre im Server war richtig; die Oberfläche wusste nur nichts davon.

Gemessen: die Liste führte `PublishAt` seit jeher mit, die Einzelabfrage
`GET /api/v1/movies/{id}` nicht — und die Detailseite entscheidet genau
daran. `publishAt` kam also immer als `null` an, die Prüfung lief ins
Leere. Dasselbe fehlte bei `GET /api/v1/series/{id}` und bei den Folgen.

Jetzt steht dort, wo sonst der Knopf stünde: „Bald ist es soweit", mit
dem Termin darunter. Kein Versprechen, das beim Klicken zurückgenommen
wird.

Der Test prüfte bisher nur die Liste — darum fiel es nicht auf. Er fragt
jetzt die Detailseite und schlägt ohne die Korrektur fehl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 15:10:30 +02:00
co-authored by Claude Opus 5
parent 255eb7c2f0
commit 343b0cdcfb
6 changed files with 46 additions and 17 deletions
+2 -1
View File
@@ -429,7 +429,8 @@ public sealed record SeriesResponse(
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State);
ItemStateResponse? State,
DateTimeOffset? PublishAt = null);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -627,7 +627,8 @@ public static class LibraryContentEndpoints
OriginalTitle: film.OriginalTitle,
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned));
SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -640,7 +641,7 @@ public static class LibraryContentEndpoints
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id)
.Select(f => new { f.Id, f.SeasonNumber })
.Select(f => new { f.Id, f.SeasonNumber, f.PublishAt })
.ToListAsync();
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
@@ -661,7 +662,8 @@ public static class LibraryContentEndpoints
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id),
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned));
SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt)));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
@@ -681,7 +683,7 @@ public static class LibraryContentEndpoints
.Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die letzte.
@@ -706,7 +708,8 @@ public static class LibraryContentEndpoints
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(naechste.Id)));
stände.GetValueOrDefault(naechste.Id),
naechste.PublishAt));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
@@ -719,7 +722,7 @@ public static class LibraryContentEndpoints
var folgen = await abfrage
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.ToListAsync();
var wurzel = await MediathekspfadFuer(db, wer,
@@ -743,7 +746,8 @@ public static class LibraryContentEndpoints
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
stände.GetValueOrDefault(f.Id),
f.PublishAt)).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
@@ -165,6 +165,16 @@ public class FreigabeTests
Assert.That(gestroemt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
// **Und die Detailseite muss es wissen.** Sie entscheidet daran, ob
// sie „Abspielen" zeigt oder den Termin. Die Liste führte `PublishAt`
// von Anfang an mit, die Einzelabfrage nicht — der Knopf stand also
// da, und wer ihn drückte, bekam eine Absage. Ein Knopf, der nichts
// tut, ist schlimmer als keiner.
var einzeln = await gast.GetFromJsonAsync<MovieResponse>(
$"/api/v1/movies/{filme.Items.Single().Id}");
Assert.That(einzeln!.PublishAt, Is.Not.Null,
"die Detailseite braucht den Termin, sonst zeigt sie Abspielen an");
// **Auch für Administratoren.** Eine Hintertür für eine Rolle wäre
// eine Regel, die manchmal gilt; wer vorziehen will, hat den Knopf.
var alsAdminGeladen = await alsAdmin.GetAsync($"/api/v1/files/{datei.Id}/download");
+2
View File
@@ -87,6 +87,8 @@ export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */
+7 -5
View File
@@ -1644,11 +1644,13 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
.pruef-gruppe strong { font-size: 14px; }
.pruef-gruppe .winzig { flex: 1 1 240px; }
/* „Verfügbar ab …" steht dort, wo sonst Abspielen stünde. */
/* Steht dort, wo sonst Abspielen stünde — kein Knopf, der nichts tut. */
.verfuegbar-ab {
display: inline-flex; align-items: center; gap: var(--s-snug);
padding: 10px 16px; border-radius: var(--r-control);
display: inline-flex; align-items: center; gap: var(--s-regular);
padding: 10px 18px; border-radius: var(--r-control);
background: var(--raised); border: 1px solid var(--line);
font-size: 14px; font-weight: 600; color: var(--text-dim);
}
.verfuegbar-ab .icon svg { width: 18px; height: 18px; }
.verfuegbar-ab .icon svg { width: 22px; height: 22px; color: var(--accent); }
.verfuegbar-text { display: flex; flex-direction: column; line-height: 1.3; }
.verfuegbar-text strong { font-size: 14px; }
.verfuegbar-text span { font-size: 13px; color: var(--text-dim); }
+14 -4
View File
@@ -251,9 +251,18 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (nochNicht) {
const hinweisZeile = document.createElement('div');
hinweisZeile.className = 'verfuegbar-ab';
hinweisZeile.append(icon('uhr'), document.createTextNode(
`Verfügbar ab ${geplantAb!.toLocaleString('de-CH',
{ dateStyle: 'long', timeStyle: 'short' })}`));
const text = document.createElement('div');
text.className = 'verfuegbar-text';
const oben = document.createElement('strong');
oben.textContent = 'Bald ist es soweit';
const unten = document.createElement('span');
unten.textContent = `Ab ${geplantAb!.toLocaleString('de-CH',
{ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit' })} Uhr`;
text.append(oben, unten);
hinweisZeile.append(icon('uhr'), text);
reihe.appendChild(hinweisZeile);
}
@@ -585,7 +594,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
block.appendChild(welche);
}
block.appendChild(aktionsreihe(
{ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei));
{ ...({} as Film), id: folge.id, files: folge.files,
publishAt: folge.publishAt ?? null } as Film, folge.state, datei));
});
return block;
}