Merge #129: series detail view

Closes #129
This commit is contained in:
Miguel Palhas
2026-08-23 20:21:15 +01:00
9 changed files with 1589 additions and 118 deletions
+38
View File
@@ -308,6 +308,44 @@
</section>
</main>
<!--
SERIES DETAIL (§4.1 + §4.2 + §9.3, issue 129): a series row anywhere
opens /series/{id} — the title line carries the audience chip, the
derived status and the wanted/on-disk count (season 0 excluded, per
§4.2). Seasons are collapsible groups: engraved name, the tracked
toggle (the one way an operator wants a season — no bulk backfill,
§4.1), counts, search and deck. Episodes read as tag, title, air date,
state, then the imported file's probed attribute chips — ffprobe truth,
not name claims — with want/search/deck only where they can do
something: an unaired episode offers no search button at all.
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="series-title"></h2>
<span class="releases-year readout dim" id="series-year"></span>
<span class="row-chips series-chips" id="series-chips"></span>
</div>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
<main class="deck releases" id="tv-releases" hidden aria-label="season and episode releases">
<header class="releases-head">
<button type="button" class="control" id="tv-releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="tv-releases-title"></h2>
<span class="releases-year readout dim" id="tv-releases-sub"></span>
</div>
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<div id="tv-buckets"></div>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
+980 -116
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -361,7 +361,7 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
async function errorDetail(response: Response): Promise<string> {
export async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
+10 -1
View File
@@ -8,7 +8,8 @@ export type Route =
| { kind: "queues" }
| { kind: "settings" }
| { kind: "search"; query: string }
| { kind: "releases"; movieId: number };
| { kind: "releases"; movieId: number }
| { kind: "series"; seriesId: number };
export function parseRoute(url: URL): Route {
const segments = url.pathname.split("/").filter(Boolean);
@@ -31,6 +32,12 @@ export function parseRoute(url: URL): Route {
return { kind: "releases", movieId };
}
}
if (segments.length === 2 && segments[0] === "series") {
const seriesId = Number(segments[1]);
if (Number.isInteger(seriesId) && seriesId > 0) {
return { kind: "series", seriesId };
}
}
return { kind: "board" };
}
@@ -48,6 +55,8 @@ export function routePath(route: Route): string {
return `/search?q=${encodeURIComponent(route.query)}`;
case "releases":
return `/movies/${route.movieId}/releases`;
case "series":
return `/series/${route.seriesId}`;
}
}
+288
View File
@@ -0,0 +1,288 @@
// Hand-written mirror of arr-api's series detail, season, episode and
// release schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
export interface ApiSeries {
id: number;
tmdb_id: number;
title: string;
year: number | null;
root_id: number;
blocked: boolean;
status: SeriesStatus;
wanted_episodes: number;
available_episodes: number;
}
export interface ApiEpisode {
id: number;
season_id: number;
number: number;
title: string;
air_date: string | null;
wanted: boolean;
state: string;
/** issue 122: gone upstream while a file of its own remained. */
vanished: boolean;
}
export interface ApiSeason {
id: number;
number: number;
tracked: boolean;
episodes: ApiEpisode[];
}
export type SeasonsOutcome =
| { kind: "seasons"; seasons: ApiSeason[] }
| { kind: "error"; detail: string };
export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
try {
const response = await fetch(`/api/series/${seriesId}`);
return response.ok ? ((await response.json()) as ApiSeries) : null;
} catch {
return null;
}
}
export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "seasons", seasons: (await response.json()) as ApiSeason[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
export interface EpisodeFile {
episode_id: number;
path: string;
size: number;
probed: {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
} | null;
waiver: string | null;
}
export type SeriesFilesOutcome =
| { kind: "files"; files: EpisodeFile[] }
| { kind: "error"; detail: string };
export async function fetchSeriesFiles(seriesId: number): Promise<SeriesFilesOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/files`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "files", files: (await response.json()) as EpisodeFile[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The season's tracked toggle (§4.1): turning it on marks every revealed
* episode wanted; turning it off withdraws nothing.
*/
export async function setSeasonTracked(
seriesId: number,
seasonNumber: number,
tracked: boolean,
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tracked }),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export async function setEpisodeWanted(episodeId: number, wanted: boolean): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/episodes/${episodeId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wanted }),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- manual triggers and decks ---------------------------------------- */
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
export interface TvTarget {
releases: () => Promise<ReleasesOutcome>;
grab: (releaseId: number) => Promise<ActionOutcome>;
search: () => Promise<ActionOutcome>;
}
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
const base = `/api/series/${seriesId}/seasons/${seasonNumber}`;
return {
releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`),
};
}
export function episodeTarget(episodeId: number): TvTarget {
const base = `/api/episodes/${episodeId}`;
return {
releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`),
};
}
async function fetchJson(url: string): Promise<ReleasesOutcome> {
try {
const response = await fetch(url);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "results", releases: (await response.json()) as MovieRelease[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
async function post(url: string): Promise<ActionOutcome> {
try {
const response = await fetch(url, { method: "POST" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The one-click override on a waived TV row (§5.1, §9.3). Overrides sit on
* the series — `episode_policy` and `season_policy` both read them there —
* so the relaxed rule is written once, then the grab proceeds.
*/
export async function waiveAndGrabTv(
seriesId: number,
target: TvTarget,
releaseId: number,
rule: string | null,
): Promise<WaiveOutcome> {
const override = waiverOverride(rule);
let overrideWritten = false;
if (override) {
try {
const current = await fetch(`/api/series/${seriesId}`);
if (!current.ok) {
return { kind: "error", detail: await errorDetail(current), overrideWritten };
}
const series = (await current.json()) as { overrides: Record<string, unknown> };
const patch = await fetch(`/api/series/${seriesId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ overrides: { ...series.overrides, ...override } }),
});
if (!patch.ok) {
return { kind: "error", detail: await errorDetail(patch), overrideWritten };
}
overrideWritten = true;
} catch {
return { kind: "error", detail: "daemon unreachable", overrideWritten };
}
}
const grabbed = await target.grab(releaseId);
if (grabbed.kind === "error") {
return { kind: "error", detail: grabbed.detail, overrideWritten };
}
return { kind: "done", overrideWritten };
}
/* ---- display helpers --------------------------------------------------- */
/** Wanted / on-disk counts for one season, whatever its number. */
export function seasonCounts(season: ApiSeason): { wanted: number; available: number } {
let wanted = 0;
let available = 0;
for (const episode of season.episodes) {
if (episode.wanted) {
wanted += 1;
if (episode.state === "available") {
available += 1;
}
}
}
return { wanted, available };
}
/**
* §6.2: do not search before the thing exists. No air date yet counts as
* not aired — TMDB has not dated it, so neither will we.
*/
export function isUnaired(airDate: string | null, now = new Date()): boolean {
if (airDate === null) {
return true;
}
const parsed = Date.parse(airDate);
if (Number.isNaN(parsed)) {
return true;
}
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return parsed > today.getTime() + 86_399_999;
}
/** `2026-08-10` as the readout spells it; unknown stays a dash. */
export function formatAirDate(airDate: string | null): string {
return airDate ?? "—";
}
/** The probed attributes a file row shows as chips (§5.6, §7.4). */
export function fileAttributeTags(file: EpisodeFile): string[] {
const probed = file.probed;
if (!probed) {
return [];
}
const tags: string[] = [];
if (probed.resolution) {
tags.push(probed.resolution);
}
if (probed.source) {
tags.push(probed.source);
}
if (probed.hdr && probed.hdr !== "SDR") {
tags.push(probed.hdr);
}
for (const track of probed.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
}
+143
View File
@@ -1101,6 +1101,135 @@ body {
padding: 0;
}
/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */
/* the title line carries its readouts beside the name, not pushed right */
.series-chips {
margin-left: var(--space-3);
}
/* one season: a collapsible group with its rule, counts and actions */
.season {
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.season-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-1);
}
/* the disclosure is a drawn chevron — no glyph standing in for an icon */
.season-disclose {
flex: none;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 150ms var(--ease-out);
}
.season-disclose:hover,
.season-disclose:focus-visible {
border-color: var(--accent);
}
.season-disclose::before {
content: "";
width: 0.4rem;
height: 0.4rem;
border-right: 2px solid var(--ink-muted);
border-bottom: 2px solid var(--ink-muted);
transform: rotate(-45deg) translate(-5%, -5%);
transition: transform 200ms var(--ease-out);
}
.season-disclose[aria-expanded="true"]::before {
transform: rotate(45deg) translate(-5%, -5%);
}
.season-name {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink);
}
/* the tracked toggle reads as the rule it is; pressed = on */
.season-line .control,
.ep-actions .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
.season-space {
flex: 1;
}
.episodes {
margin: 0;
padding: 0 0 var(--space-2) var(--space-8);
list-style: none;
}
.episode {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.ep-tag {
flex: none;
width: 2.75rem;
color: var(--ink-faint);
}
.ep-title {
min-width: 0;
font-size: var(--text-sm);
color: var(--ink);
}
.ep-air {
flex: none;
width: 6.5rem;
color: var(--ink-faint);
}
.ep-chips {
margin-left: auto;
}
.ep-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: var(--space-3);
}
.ep-unaired {
font-size: var(--text-xs);
}
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
.chip[data-flag="vanished"] {
color: var(--signal-warn);
border-style: dashed;
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
/* the badge is a signal, not a control: amber attention on the cyan word */
@@ -1194,6 +1323,20 @@ body {
flex-basis: 100%;
}
/* an episode row wraps: identity first, chips full-width, actions right */
.ep-chips {
margin-left: 0;
flex-basis: 100%;
}
.ep-actions {
margin-left: auto;
}
.episodes {
padding-left: var(--space-4);
}
.releases-id {
order: -1;
flex-basis: 100%;