From 06a68372e6c17ad9a6142748e94c4a6c2f619f85 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:28 +0100 Subject: [PATCH 1/2] feat(api): expose series and season on episodes --- crates/arr-api/src/series.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index e474d2c..820d9d8 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -107,7 +107,11 @@ pub struct Season { #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Episode { pub id: i64, + /// The owning series — the episode deck route needs it without walking + /// seasons first. + pub series_id: i64, pub season_id: i64, + pub season_number: i64, pub number: i64, pub title: String, pub air_date: Option, @@ -660,7 +664,9 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A .filter(|episode| episode.season_id == season.id) .map(|episode| Episode { id: episode.id, + series_id, season_id: episode.season_id, + season_number: episode.season_number, number: episode.number, title: episode.title.clone(), air_date: episode.air_date.clone(), @@ -881,7 +887,9 @@ async fn load_episode(state: &AppState, id: i64) -> Result { .ok_or(ApiError::EpisodeNotFound)?; Ok(Episode { id: row.id, + series_id: row.series_id, season_id: row.season_id, + season_number: row.season_number, number: row.number, title: row.title, air_date: row.air_date, From cf36a4302cd492d37fb502b50b8eef52ad287643 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:28 +0100 Subject: [PATCH 2/2] feat(web): route season and episode release decks --- web/src/main.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++- web/src/router.ts | 31 +++++++++++++++++++++++- web/src/series.ts | 12 ++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 5f00c30..bbf393f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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("#nav-library"), must("#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("#nav-library"), + must("#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("#series-back"), + returnTo: must("#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}`, diff --git a/web/src/router.ts b/web/src/router.ts index 48a737f..3bd35f0 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -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`; } } diff --git a/web/src/series.ts b/web/src/series.ts index 26b9692..f29fffa 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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 { } } +/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */ +export async function fetchEpisode(episodeId: number): Promise { + 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 { try { const response = await fetch(`/api/series/${seriesId}/seasons`);