diff --git a/web/index.html b/web/index.html
index b49b3d4..dd45e56 100644
--- a/web/index.html
+++ b/web/index.html
@@ -495,6 +495,28 @@
+
+
+
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;
}