feat(web): series, season and episode removal

Series detail exposes DELETE /api/series/{id} through the movie
removePanel, generalised over endpoints and file rollup instead of
copied. Season headers and on-disk episode rows get the settings
armed-delete control wired to #174's file endpoints, worded for what
they do: files go, wanted clears, the row stays listed.

Closes #175

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 17:09:46 +01:00
parent 3dda666273
commit 7c30928907
6 changed files with 275 additions and 27 deletions
+22
View File
@@ -495,6 +495,28 @@
</div>
</section>
<!-- removal is the one library control a series carries (issue 175):
the same arming panel a movie uses — evidence first, then the
decision — wired to DELETE /api/series/{id} -->
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
>
remove
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
</section>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
+175 -23
View File
@@ -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<ActionOutcome>;
/** 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<void>;
/** 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<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#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<HTMLElement>(".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) ------------------------ */
+2 -2
View File
@@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise<FilesOutcome> {
* 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);
}
+62
View File
@@ -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<string>();
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<ActionOutcome> {
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<ActionOutcome> {
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<ActionOutcome> {
return del(`/api/episodes/${episodeId}/files`);
}
async function del(url: string): Promise<ActionOutcome> {
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. */
+3 -2
View File
@@ -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;
+11
View File
@@ -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;
}