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:
+175
-23
@@ -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) ------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user