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:
@@ -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>
|
||||
|
||||
|
||||
+169
-17
@@ -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,7 +1781,15 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
if (!movie) {
|
||||
return;
|
||||
}
|
||||
const panel = removePanel(movie, {
|
||||
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;
|
||||
@@ -1783,7 +1797,8 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user