feat(web): route season and episode release decks

This commit is contained in:
Miguel Palhas
2026-08-23 20:44:28 +01:00
parent 06a68372e6
commit cf36a4302c
3 changed files with 101 additions and 2 deletions
+59 -1
View File
@@ -63,6 +63,7 @@ import {
type ApiSeries,
type EpisodeFile,
episodeTarget,
fetchEpisode,
fetchSeasons,
fetchSeries,
fetchSeriesFiles,
@@ -305,7 +306,7 @@ function main() {
// the surface underneath must be live before the detail view covers
// it, so back and Esc land somewhere real (same as a movie deep link)
library.open();
seriesDetail.open(
await seriesDetail.open(
route.seriesId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
@@ -315,6 +316,61 @@ function main() {
);
break;
}
case "seasonReleases":
case "episodeReleases": {
// both deck routes live over the series detail view, exactly as a
// click on a deck button does; resolve what that click would have
let title: string;
let sub: string;
let seriesId: number;
let target: TvTarget;
if (route.kind === "seasonReleases") {
seriesId = route.seriesId;
target = seasonTarget(route.seriesId, route.seasonNumber);
sub = `season ${PAD_TWO(route.seasonNumber)} · packs`;
title = (await fetchSeries(route.seriesId))?.title ?? "";
if (title === "") {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
} else {
const episode = await fetchEpisode(route.episodeId);
if (!episode) {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
seriesId = episode.series_id;
target = episodeTarget(episode.id);
sub = `S${PAD_TWO(episode.season_number)}E${PAD_TWO(episode.number)} · ${episode.title}`;
title = (await fetchSeries(seriesId))?.title ?? "";
if (title === "") {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
}
library.open();
await seriesDetail.open(
seriesId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
break;
}
}
}
@@ -2327,6 +2383,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
if (currentId === null) {
return;
}
navigate({ kind: "seasonReleases", seriesId: currentId, seasonNumber: season.number });
tvDeck.open({
title: currentTitle,
sub: `season ${PAD_TWO(season.number)} · packs`,
@@ -2470,6 +2527,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
if (currentId === null) {
return;
}
navigate({ kind: "episodeReleases", episodeId: episode.id });
tvDeck.open({
title: currentTitle,
sub: `S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} · ${episode.title}`,
+30 -1
View File
@@ -9,7 +9,9 @@ export type Route =
| { kind: "settings" }
| { kind: "search"; query: string }
| { kind: "releases"; movieId: number }
| { kind: "series"; seriesId: number };
| { kind: "series"; seriesId: number }
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
| { kind: "episodeReleases"; episodeId: number };
export function parseRoute(url: URL): Route {
const segments = url.pathname.split("/").filter(Boolean);
@@ -38,6 +40,29 @@ export function parseRoute(url: URL): Route {
return { kind: "series", seriesId };
}
}
if (
segments.length === 5 &&
segments[0] === "series" &&
segments[2] === "seasons" &&
segments[4] === "releases"
) {
const seriesId = Number(segments[1]);
const seasonNumber = Number(segments[3]);
if (
Number.isInteger(seriesId) &&
seriesId > 0 &&
Number.isInteger(seasonNumber) &&
seasonNumber >= 0
) {
return { kind: "seasonReleases", seriesId, seasonNumber };
}
}
if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") {
const episodeId = Number(segments[1]);
if (Number.isInteger(episodeId) && episodeId > 0) {
return { kind: "episodeReleases", episodeId };
}
}
return { kind: "board" };
}
@@ -57,6 +82,10 @@ export function routePath(route: Route): string {
return `/movies/${route.movieId}/releases`;
case "series":
return `/series/${route.seriesId}`;
case "seasonReleases":
return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`;
case "episodeReleases":
return `/episodes/${route.episodeId}/releases`;
}
}
+12
View File
@@ -22,7 +22,9 @@ export interface ApiSeries {
export interface ApiEpisode {
id: number;
series_id: number;
season_id: number;
season_number: number;
number: number;
title: string;
air_date: string | null;
@@ -54,6 +56,16 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
}
}
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
try {
const response = await fetch(`/api/episodes/${episodeId}`);
return response.ok ? ((await response.json()) as ApiEpisode) : null;
} catch {
return null;
}
}
export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons`);