+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", mf.path AS \"path!: String\", mf.size AS \"size!: i64\", mf.probed AS \"probed?: serde_json::Value\", json_extract(mf.waiver, '$.rule') AS \"waiver?: String\" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "probed?: serde_json::Value",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540"
|
||||
}
|
||||
@@ -92,6 +92,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(series::list_owners))
|
||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||
.routes(routes!(owners::list, owners::create))
|
||||
|
||||
@@ -1140,6 +1140,39 @@ pub async fn grab_season_release(
|
||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||
}
|
||||
|
||||
/// One imported episode file, keyed to its episode so the detail view can
|
||||
/// attach the file's probed §7.4 attributes to the episode row.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct EpisodeFile {
|
||||
pub episode_id: i64,
|
||||
pub path: String,
|
||||
pub size: i64,
|
||||
pub probed: Option<serde_json::Value>,
|
||||
/// The §5.7 rule relaxed to allow this import, when one was.
|
||||
pub waiver: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/files", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
responses(
|
||||
(status = 200, body = [EpisodeFile]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<EpisodeFile>>, ApiError> {
|
||||
load_series_row(&state, id).await?;
|
||||
let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/owners", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
@@ -1956,6 +1989,57 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// The detail view reads its episode files through the series, keyed by
|
||||
/// episode id, so one request carries every §7.4 attribute tag it shows.
|
||||
#[tokio::test]
|
||||
async fn series_files_are_keyed_by_episode() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"number": 2, "title": "Hospital", "air_date": "2018-10-02"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let episodes = season["episodes"].as_array().expect("episodes");
|
||||
let first = episodes[0]["id"].as_i64().expect("episode id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
sqlx::query(
|
||||
r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
|
||||
VALUES ('episode', ?, ?, 7, ?, ?)",
|
||||
)
|
||||
.bind(first)
|
||||
.bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv")
|
||||
.bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#)
|
||||
.bind(r#"{"rule":"required_audio"}"#)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/series/{series_id}/files"))
|
||||
.await
|
||||
.expect("fetch files");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let files: serde_json::Value = response.json().await.expect("files json");
|
||||
let rows = files.as_array().expect("array");
|
||||
assert_eq!(rows.len(), 1, "only imported episodes carry a file");
|
||||
assert_eq!(rows[0]["episode_id"], first);
|
||||
assert_eq!(rows[0]["probed"]["resolution"], "1080p");
|
||||
assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT");
|
||||
assert_eq!(rows[0]["waiver"], "required_audio");
|
||||
|
||||
let missing = reqwest::get(format!("{base}/api/series/999/files"))
|
||||
.await
|
||||
.expect("fetch files");
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// The guard that keeps a delete inside the library: a path that is not
|
||||
/// under the series' root is left alone, whatever the row says.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user