From c37031207e98e7847674d9d78862ca4f891767e1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:20:13 +0100 Subject: [PATCH 1/7] docs: amend DESIGN.md for four rulings Untracking a season now clears its episodes' wanted flag, unified search returns titles only, the library is the homepage and the signal chain moves into settings, and the wordmark links home. Refs #169 --- DESIGN.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4187cd3..c2b07f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -139,9 +139,12 @@ manually marked S02 needs no special case: three wanted episodes, nothing else. `Season.tracked` is also a rule, not intent. Turning tracking on marks every already-revealed episode in the season wanted, and keeps marking episodes -wanted as metadata reveals them. Turning it off withdraws nothing: leaf intent -is never removed implicitly, so anything already marked wanted stays wanted -until cleared explicitly. +wanted as metadata reveals them. Turning it off clears `wanted` on every +episode of that season: toggling the flag is itself the explicit act, and the +product offers no other way to withdraw a season's worth of intent. Without +that, untracking leaves a series with every season off and every episode still +wanted, pinned at `incomplete` with no way back. Marking individual episodes +wanted on a season that was never tracked is unaffected by any of this. Both rules skip season 0. Specials are dozens of undated shorts and recaps that indexers do not carry, so `auto_track` never marks season 0 tracked. See @@ -485,12 +488,10 @@ lower-priority crate** (§9.4), not the primary shape. ### 9.2 Unified search -One box. Two grouped result sets: **in library** first (title match, and for TV -also episode title, so `bluey hospital` finds the episode), **on TMDB** below. -An episode row surfaces only when the query matches something beyond the series -title: a query satisfied by the series title alone returns the series row and -nothing below it. Enter on a TMDB result opens the add flow with root and -policy pre-filled. +One box. Two grouped result sets: **in library** first, **on TMDB** below. Both +sets are titles — series and movies. Episode rows never appear, under any +query, and there are no season rows. Enter on a TMDB result opens the add flow +with root and policy pre-filled. The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`, which skips to the manual-grab flow. @@ -597,6 +598,22 @@ Out of scope here, because they are the adjacent scope most likely to creep: watch providers, recommendations or similar titles, collections, person pages inside the app, review text. +### 9.7 The shell + +**The library is the homepage.** `/` renders the library view described in +§9.6. It is what the operator opens the app to look at, so it is what the app +opens on. + +**The signal chain is a settings section.** The four upstreams — tmdb, +prowlarr, arr, transmission — and their lamps live inside `/settings`, and have +no route of their own. Per-upstream health is something you check when +something is wrong, not a homepage. + +**The master lamp stays on the rail**, so the at-a-glance read that the chain +is healthy survives the move and no navigation is needed to get it. + +**The wordmark is a link home.** `arr` on the rail navigates to `/`. + ## 10. Persistence SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`. From fada2ff89a103fcc5f00659d9b082bcef367ea4d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:24:53 +0100 Subject: [PATCH 2/7] fix(web): centre deck chips in their boxes --- web/src/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/style.css b/web/src/style.css index 93e5fc6..3177b3e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1034,7 +1034,9 @@ body { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - line-height: 1.25rem; + /* single nowrap line: line-height equal to the box height centres it, + and keeps ellipsis + text-align working, which flex would break */ + line-height: var(--control-h); } .cw-score { From 9e003823c6e29b75cd54318b2216ced7bd6f1733 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:31:09 +0100 Subject: [PATCH 3/7] feat: untracking a season clears its wanted --- ...c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json | 86 +++++++++++++++++ crates/arr-api/src/series.rs | 96 +++++++++++++++---- crates/arr-core/src/tracking.rs | 35 +++---- 3 files changed, 183 insertions(+), 34 deletions(-) create mode 100644 .sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json diff --git a/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json b/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json new file mode 100644 index 0000000..207dad9 --- /dev/null +++ b/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "season_id!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "air_date", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "state!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "vanished!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "search_attempts!: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + true + ] + }, + "hash": "3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed" +} diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index bace102..632799c 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use arr_core::tracking::{apply_auto_track, RefreshedSeason}; +use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; use arr_core::{ derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, SeriesStatus, TitleOverrides, @@ -153,9 +153,11 @@ pub struct CreateEpisode { /// Both fields of a season a person can set by hand. /// /// Turning `tracked` on marks every already-revealed episode wanted, and -/// episodes revealed later follow while it stays on; turning it off withdraws -/// nothing (§4.1). `wanted` is the one-click "grab this season", and writes -/// intent onto every episode already in it — the two are deliberately +/// episodes revealed later follow while it stays on; turning it off clears +/// `wanted` on all of them (§4.1, #171) — idempotently, so re-sending +/// `tracked: false` to an already-untracked season still clears its +/// stranded wanted rows. `wanted` is the one-click "grab this season", and +/// writes intent onto every episode already in it — the two are deliberately /// separate (§4.1). #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateSeason { @@ -870,24 +872,40 @@ pub async fn update_season( .ok_or(ApiError::SeasonNotFound)?; let season_id = season.id; + // One transaction: the flag write and the episode intent it implies + // land together or not at all (#171). + let mut transaction = pool(&state)?.begin().await?; + if let Some(tracked) = input.tracked { sqlx::query!( "UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", tracked, season_id ) - .execute(pool(&state)?) + .execute(&mut *transaction) .await?; // §4.1, as `arr_core::tracking::apply_tracked` decides it: turning - // tracking on marks every already-revealed episode wanted; turning it - // off withdraws nothing. Only the off -> on transition writes intent. - if tracked && !season.tracked { + // tracking on marks every already-revealed episode wanted; turning + // it off clears them all, season 0 included. The rule runs even when + // the flag already holds this value (#171): re-sending `false` to an + // untracked season is how stranded wanted rows get cleared. + let rows = sqlx::query_as!( + EpisodeRow, + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#, + season_id + ) + .fetch_all(&mut *transaction) + .await?; + let mut episodes: Vec<_> = rows.iter().map(core_episode).collect(); + apply_tracked(tracked, &mut episodes); + for episode in &episodes { sqlx::query!( - "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?", - true, - season_id + "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + episode.wanted, + episode.id.0 ) - .execute(pool(&state)?) + .execute(&mut *transaction) .await?; } } @@ -899,9 +917,10 @@ pub async fn update_season( wanted, season_id ) - .execute(pool(&state)?) + .execute(&mut *transaction) .await?; } + transaction.commit().await?; let seasons = load_seasons(&state, series_id).await?; seasons @@ -1768,8 +1787,12 @@ mod tests { assert_eq!(episode["wanted"], false); } + /// #171. The §4.1 rule runs whenever the flag is sent: off clears + /// wanted — including on a season that was already untracked, which is + /// how rows stranded by the old ruling get out — and on marks every + /// revealed episode wanted. #[tokio::test] - async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() { + async fn untracking_a_season_clears_its_episodes_wanted() { async fn patch_season( base: &str, series_id: i64, @@ -1803,6 +1826,26 @@ mod tests { assert_eq!(season["tracked"], false); assert_eq!(season["episodes"][0]["wanted"], false); + // A season stranded by the old ruling: untracked, yet its episodes + // are all wanted. Re-sending the flag it already holds must still + // do the work (#171). + let updated = patch_season(&base, series_id, serde_json::json!({"wanted": true})).await; + assert!(updated["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == true)); + let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; + assert_eq!(updated["tracked"], false); + assert!( + updated["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == false), + "#171: clearing an already-untracked season is the way out" + ); + let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await; assert_eq!(updated["tracked"], true); assert!( @@ -1814,7 +1857,6 @@ mod tests { "§4.1: turning tracked on marks every revealed episode wanted" ); - // Turning it off withdraws nothing. let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; assert_eq!(updated["tracked"], false); assert!( @@ -1822,11 +1864,31 @@ mod tests { .as_array() .expect("episodes") .iter() - .all(|episode| episode["wanted"] == true), - "§4.1: leaf intent is never removed implicitly" + .all(|episode| episode["wanted"] == false), + "§4.1: turning tracked off clears every revealed episode" ); } + /// #171. Untracking a season with no episodes touches nothing and is not + /// an error. + #[tokio::test] + async fn untracking_an_empty_season_is_a_no_op() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season(&base, series_id, 1, serde_json::json!([])).await; + assert_eq!(season["tracked"], false); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/1")) + .json(&serde_json::json!({"tracked": false})) + .send() + .await + .expect("update season"); + assert_eq!(response.status(), StatusCode::OK); + } + #[tokio::test] async fn listed_series_carry_a_derived_status() { let (_dir, state, base) = application().await; diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index e6513ec..9370828 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -38,14 +38,16 @@ pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [Re /// Applies a season's tracking rule (`DESIGN.md` §4.1) to its /// already-revealed episodes. /// -/// Turning tracking on marks every revealed episode wanted; future reveals -/// follow while the season stays tracked. Turning it off withdraws nothing: -/// leaf intent is never removed implicitly. +/// Turning tracking on marks every revealed episode wanted, and future +/// reveals follow while the season stays tracked. Turning it off clears +/// `wanted` on all of them: toggling the flag is itself the explicit act, +/// and the product offers no other way to withdraw a season's worth of +/// intent. The rule is idempotent and must run even when the flag already +/// has the value asked for (#171) — an operator re-sending `tracked: false` +/// to an already-untracked season is clearing stranded intent. pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) { - if tracked { - for episode in episodes { - episode.wanted = true; - } + for episode in episodes { + episode.wanted = tracked; } } @@ -216,8 +218,12 @@ mod tests { } } + /// #171. Toggling the flag is the explicit act, so `false` clears + /// wanted on every revealed episode — including when it is re-sent to a + /// season that was already untracked, which is the only way out for + /// rows stranded by the old ruling. #[test] - fn tracked_off_withdraws_nothing() { + fn tracked_off_clears_revealed_episodes_wanted() { let cases: [&[bool]; 4] = [ &[], &[false], @@ -236,18 +242,13 @@ mod tests { ) }) .collect::>(); - let before = episodes - .iter() - .map(|episode| episode.wanted) - .collect::>(); apply_tracked(false, &mut episodes); - let after = episodes - .iter() - .map(|episode| episode.wanted) - .collect::>(); - assert_eq!(before, after, "tracked off must not touch intent"); + assert!( + episodes.iter().all(|episode| !episode.wanted), + "tracked off must clear every revealed episode, input {wanted:?}" + ); } } } From 7ef24088a5f6ba02b19b8474fec79d1e3c91646c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:39:07 +0100 Subject: [PATCH 4/7] feat(web): library homepage, chain in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the §9.7 shell rulings (issue #173): "/" renders the library and the board route is gone — every former board target (Esc, cleared search, detail parent routes, the not-found fallback) lands on the library. The signal chain moves into /settings as its first section, lamps still driven by the health poll; the master lamp stays on the rail. The wordmark is now a real anchor that navigates home. Co-Authored-By: Claude Fable 5 --- web/index.html | 141 +++++++++++++++++++++++--------------------- web/src/main.ts | 114 +++++++++++++++-------------------- web/src/router.ts | 13 ++-- web/src/settings.ts | 9 ++- web/src/style.css | 39 ++++-------- 5 files changed, 143 insertions(+), 173 deletions(-) diff --git a/web/index.html b/web/index.html index 8891f4c..b49b3d4 100644 --- a/web/index.html +++ b/web/index.html @@ -63,12 +63,18 @@ nothing needs them. The rail control carries an amber entry count, refreshed with the health poll; amber is attention, cyan stays the interaction hue. + SHELL (§9.7, issue 173): the library is the homepage — "/" renders the + library deck, and the wordmark on the rail is a link home. The signal + chain above is no longer a route: it lives as the first section of + /settings, its lamps still driven by the health poll, and the master + lamp stays on the rail so the one-glance read survives the move. Where + earlier notes here say "returns to the board", read "the library". -->
-

arr

+ arr
+
+
+

signal chain

+
+
+
+
+ +

tmdb

+ probing +
+

metadata in

+

titles, languages, release dates

+
+ + + +
+
+ +

prowlarr

+ probing +
+

indexers in

+

torznab search and rss

+
+ + + +
+
+ +

arr

+ probing +
+

reconcile core

+

decide · find · fetch · import

+
+ + + +
+
+ +

transmission

+ probing +
+

torrents out

+

rpc · grab and seed

+
+
+
+ + +
+
+

library

+
+
+ + +
+ +
+
    diff --git a/web/src/main.ts b/web/src/main.ts index 8f06866..b1d6a33 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -32,6 +32,7 @@ import { type SeriesAttention, } from "./queues"; import { + type ActionOutcome, bucketOf, type FilesOutcome, formatAudio, @@ -44,7 +45,6 @@ import { formatSweepAge, grabRelease, libraryFolder, - type MovieFile, type MovieRelease, movieFiles, movieReleases, @@ -87,16 +87,20 @@ import { fileAttributeTags, formatAirDate, isUnaired, + removeEpisodeFiles, + removeSeasonFiles, + removeSeries, type SeriesMetadata, seasonCounts, seasonTarget, + seriesFolder, seriesMetadata, setEpisodeWanted, setSeasonTracked, type TvTarget, waiveAndGrabTv, } from "./series"; -import { settingsMain } from "./settings"; +import { armedDelete, settingsMain } from "./settings"; import "./style.css"; const POLL_MS = 15_000; @@ -257,7 +261,7 @@ function main() { views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings); const search = searchMain(movieDetail, seriesDetail, views, goHome); // a removed title must not survive on the surface the page opened over - movieDetail.setRemoved((parent) => { + const openAfterRemoval = (parent: Route) => { switch (parent.kind) { case "library": library.open(); @@ -272,7 +276,9 @@ function main() { library.open(); break; } - }); + }; + movieDetail.setRemoved(openAfterRemoval); + seriesDetail.setRemoved(openAfterRemoval); refreshQueuesBadge = () => { void queues.refreshBadge(); }; @@ -1775,15 +1781,24 @@ function movieMain(views: HideableView[]): MovieView { if (!movie) { return; } - const panel = removePanel(movie, { - cancel: closeRemove, - removed: () => { - const parent = parentRoute; - clearRemove(); - close(); - removed?.(parent); + const panel = removePanel( + { + title: movie.title, + files: () => movieFiles(movie.id), + folder: libraryFolder, + remove: () => removeMovie(movie.id), + seedLine: "the torrent keeps seeding until its tracker rule clears.", }, - }); + { + cancel: closeRemove, + removed: () => { + const parent = parentRoute; + clearRemove(); + close(); + removed?.(parent); + }, + }, + ); removeWrap.replaceChildren(panel); removeWrap.hidden = false; remove.setAttribute("aria-expanded", "true"); @@ -1936,6 +1951,27 @@ interface RemoveActions { removed: () => void; } +/** The slice of a file the removal panel reads: evidence, nothing else. */ +interface RemoveFile { + path: string; + size: number; +} + +/** + * What the panel needs to know about a title (issue 175): a movie and a + * series differ only in which endpoints they call, how their files roll up + * to one folder, and how many torrents the §7.3 warning speaks of. + */ +interface RemoveSubject { + title: string; + files: () => Promise<{ kind: "files"; files: RemoveFile[] } | { kind: "error"; detail: string }>; + /** The one folder the delete takes, when the files agree on it. */ + folder: (files: RemoveFile[]) => string | null; + remove: () => Promise; + /** What does not happen: seeding continues under its own rule (§7.3). */ + seedLine: string; +} + /** * The removal confirmation (issue 104, simplified by 110): removing a title * always unlinks its §7.4 folder, so there is one decision, not two. @@ -1943,15 +1979,16 @@ interface RemoveActions { * It names the §7.4 folder it would unlink rather than promising in the * abstract — the service knows only what it wrote (§2), so the file list is * the whole truth about what disappears. And it says what does not happen: - * the torrent keeps seeding under its own rule (§7.3). + * seeding continues under its own rule (§7.3). */ -function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { +function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElement { const panel = document.createElement("div"); panel.className = "remove-body"; panel.setAttribute("role", "group"); - panel.setAttribute("aria-label", `remove ${movie.title}`); + panel.setAttribute("aria-label", `remove ${subject.title}`); - let files: MovieFile[] | null = null; + let files: RemoveFile[] | null = null; + let folderNamed = false; const evidence = document.createElement("p"); evidence.className = "remove-evidence readout dim"; @@ -1982,8 +2019,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { const hasFiles = files !== null && files.length > 0; panel.dataset.armed = String(hasFiles); if (hasFiles) { - note.textContent = - "deletes the folder above. the torrent keeps seeding until its tracker rule clears."; + note.textContent = `${folderNamed ? "deletes the folder above." : "deletes the files above."} ${subject.seedLine}`; note.dataset.tone = "warn"; return; } @@ -1996,7 +2032,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { cancel.disabled = true; delete note.dataset.tone; note.textContent = "removing title and files…"; - void removeMovie(movie.id).then((outcome) => { + void subject.remove().then((outcome) => { if (outcome.kind === "done") { actions.removed(); return; @@ -2009,7 +2045,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { }); }); - void movieFiles(movie.id).then((outcome) => { + void subject.files().then((outcome) => { if (outcome.kind === "error") { evidence.textContent = `files unreadable — ${outcome.detail}`; paint(); @@ -2023,7 +2059,8 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { } const count = `${files.length} ${files.length === 1 ? "file" : "files"}`; evidence.textContent = `${count} · ${formatSize(totalSize(files))}`; - const folder = libraryFolder(files); + const folder = subject.folder(files); + folderNamed = folder !== null; path.hidden = false; path.textContent = folder ?? files.map((file) => file.path).join("\n"); paint(); @@ -2928,6 +2965,8 @@ interface SeriesView { returnTo: HTMLElement, parentRoute: Route, ) => Promise; + /** Where to land once the title is gone — same contract as a movie. */ + setRemoved: (handler: (parent: Route) => void) => void; } const PAD_TWO = (value: number): string => String(value).padStart(2, "0"); @@ -2948,6 +2987,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const actionsEl = must("#series-actions"); const statusEl = must("#series-status"); const seasonsList = must("#rows-seasons"); + const remove = must("#series-remove"); + const removeWrap = must("#series-remove-panel"); let roots: Root[] = []; let series: ApiSeries | null = null; @@ -2963,6 +3004,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { let focusKey: string | null = null; // guards a stale fetch from painting over a newer view let sequence = 0; + let removed: ((parent: Route) => void) | null = null; function setStatus(text: string | null, tone?: "fault") { statusEl.hidden = text === null; @@ -2974,6 +3016,59 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } } + /* ---- removal confirmation: the movie panel, generalised (issue 175) ---- */ + + /** Tears the confirmation down without stealing focus from a caller. */ + function clearRemove() { + removeWrap.hidden = true; + removeWrap.replaceChildren(); + remove.setAttribute("aria-expanded", "false"); + } + + function closeRemove() { + clearRemove(); + remove.focus(); + } + + function openRemove() { + const current = series; + const id = seriesId; + if (!current || id === null) { + return; + } + const panel = removePanel( + { + title: current.title, + files: () => fetchSeriesFiles(id), + folder: seriesFolder, + remove: () => removeSeries(id), + seedLine: "torrents keep seeding until their tracker rules clear.", + }, + { + cancel: closeRemove, + removed: () => { + const parent = parentRoute; + clearRemove(); + close(); + removed?.(parent); + }, + }, + ); + removeWrap.replaceChildren(panel); + removeWrap.hidden = false; + remove.setAttribute("aria-expanded", "true"); + // cancel takes focus, not the destructive action (same as a movie) + panel.querySelector(".remove-actions .control:last-child")?.focus(); + } + + remove.addEventListener("click", () => { + if (removeWrap.hidden) { + openRemove(); + } else { + closeRemove(); + } + }); + function paintHeader() { const current = series; if (!current) { @@ -3200,6 +3295,32 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { ); } line.append(space, deckBtn); + // #174: files go, episodes stop being wanted, the season stays listed. + // Only offered with files on disk — intent alone is the tracked toggle. + if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) { + const clear = armedDelete("remove files", () => { + const currentId = seriesId; + if (currentId === null) { + return; + } + void removeSeasonFiles(currentId, season.number).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // the control itself disappears with the files; the tracked + // toggle is the season's control that survives the repaint + focusKey = `track-${season.number}`; + void load(); + }); + }); + clear.setAttribute( + "aria-label", + `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, + ); + line.append(clear); + } item.append(line); const episodes = document.createElement("ul"); @@ -3262,7 +3383,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const onDisk = episode.state === "available"; if (aired && onDisk) { - // present and correct: nothing to decide here + // #174: the file goes and the episode stops being wanted; the row + // stays listed. Same arm-then-confirm as a settings row. + const clear = armedDelete("remove file", () => { + void removeEpisodeFiles(episode.id).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // once missing, the row's want control is what remains to focus + focusKey = `want-${episode.id}`; + void load(); + }); + }); + clear.setAttribute( + "aria-label", + `remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`, + ); + actions.append(clear); } else if (!aired) { const note = document.createElement("span"); note.className = "readout dim ep-unaired"; @@ -3392,6 +3531,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { focusKey = null; deckEl.hidden = true; view.hidden = false; + clearRemove(); clearRichDetail(); back.focus(); await load(); @@ -3402,6 +3542,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { seriesId = null; seasons = null; sequence += 1; + clearRemove(); clearRichDetail(); } @@ -3421,6 +3562,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { (event) => { if (event.key === "Escape" && !view.hidden) { event.stopImmediatePropagation(); + // the confirmation is the innermost layer: Esc abandons it first + if (!removeWrap.hidden) { + closeRemove(); + return; + } navigate(parentRoute); close(); } @@ -3428,7 +3574,13 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { true, ); - return { hide, open }; + return { + hide, + open, + setRemoved: (handler: (parent: Route) => void) => { + removed = handler; + }, + }; } /* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */ diff --git a/web/src/releases.ts b/web/src/releases.ts index 1bb6480..2e74b21 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise { * names everything a files-too remove takes. `null` when the files disagree, * which the panel then says instead of naming one folder falsely. */ -export function libraryFolder(files: MovieFile[]): string | null { +export function libraryFolder(files: { path: string }[]): string | null { const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/")))); if (folders.size !== 1) { return null; @@ -140,7 +140,7 @@ export function libraryFolder(files: MovieFile[]): string | null { return folder === undefined || folder === "" ? null : folder; } -export function totalSize(files: MovieFile[]): number { +export function totalSize(files: { size: number }[]): number { return files.reduce((sum, file) => sum + file.size, 0); } diff --git a/web/src/series.ts b/web/src/series.ts index 7fb7b2d..f5dbedc 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -149,6 +149,68 @@ export async function setEpisodeWanted(episodeId: number, wanted: boolean): Prom } } +/* ---- removal (issues 174 + 175) ---------------------------------------- */ + +/** + * The §7.4 title folder these episode files share. Season subfolders differ + * between files, so the shared folder is the one carrying the `[tmdbid-…]` + * tag every §7.4 title folder name has. `null` when the files disagree, + * which the panel then says instead of naming one folder falsely. + */ +export function seriesFolder(files: { path: string }[]): string | null { + const folders = new Set(); + for (const file of files) { + const parts = file.path.split("/"); + const titleAt = parts.findIndex((part) => part.includes("[tmdbid-")); + const folder = + titleAt > 0 + ? parts.slice(0, titleAt + 1).join("/") + : file.path.slice(0, file.path.lastIndexOf("/")); + if (folder === "") { + return null; + } + folders.add(folder); + } + if (folders.size !== 1) { + return null; + } + return [...folders][0] ?? null; +} + +/** + * Remove the series from the library. The row and its §7.4 title folder go. + * Torrents keep seeding — the reaper owns that lifecycle (§7.3). + */ +export function removeSeries(seriesId: number): Promise { + return del(`/api/series/${seriesId}`); +} + +/** + * #174: the season's files go and its episodes stop being wanted. The season + * stays listed — TMDB owns that metadata and the next refresh would recreate + * it anyway. + */ +export function removeSeasonFiles(seriesId: number, seasonNumber: number): Promise { + return del(`/api/series/${seriesId}/seasons/${seasonNumber}/files`); +} + +/** #174, one episode: the file goes and the episode stops being wanted. */ +export function removeEpisodeFiles(episodeId: number): Promise { + return del(`/api/episodes/${episodeId}/files`); +} + +async function del(url: string): Promise { + try { + const response = await fetch(url, { method: "DELETE" }); + 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. */ diff --git a/web/src/settings.ts b/web/src/settings.ts index 2d44022..f0736d4 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -188,9 +188,10 @@ function listValue(input: HTMLInputElement): string[] { /** * First click arms the destructive action, second confirms. The armed state * clears on blur or after a few seconds, so an accidental double click - * never deletes. + * never deletes. Shared with the series detail rows — one confirmation + * idiom for row-level destruction, not one per page. */ -function armedDelete(label: string, execute: () => void): HTMLButtonElement { +export function armedDelete(label: string, execute: () => void): HTMLButtonElement { const button = el("button", "control control-quiet readout", label); button.type = "button"; let armed = false; diff --git a/web/src/style.css b/web/src/style.css index 7791d3f..f44f7ca 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -995,6 +995,17 @@ body { border-color: var(--signal-warn); } +/* the settings arm-then-confirm, shared with season and episode rows: an + armed control turns caution amber until it disarms or fires */ +.control[data-armed="true"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.control[data-armed="true"]:hover:not(:disabled) { + border-color: var(--signal-warn); +} + .bucket-head { align-items: center; }