feat(api,web): remove a movie, files optional
ci / web (push) Successful in 28s
ci / rust (push) Successful in 1m24s
e2e / e2e (push) Successful in 1m19s

`DELETE /api/movies/{id}` took the row and left the files, and no
surface exposed it. It now accepts `delete_files`, which unlinks the
title's §7.4 folder — atomic, so sidecars go with the feature. Targets
come from `media_files`, never from re-deriving the folder name, and a
path outside its root is never touched. The torrent is untouched (§7.3):
it keeps seeding and the reaper owns it.

The release deck grows a quiet REMOVE control opening one inline
confirmation: file count, size and folder first, then a delete-files
toggle that starts off, then what the choice costs.

Closes #104
This commit is contained in:
Miguel Palhas
2026-08-23 09:08:07 +01:00
parent 83ca921501
commit 4c73b3e7c6
11 changed files with 783 additions and 5 deletions
+221 -3
View File
@@ -26,12 +26,17 @@ import {
formatSource,
formatSweepAge,
grabRelease,
libraryFolder,
type MovieFile,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
queueSearch,
removeMovie,
ruleLabel,
sweepExpected,
totalSize,
waiveAndGrab,
waiverOverride,
} from "./releases";
@@ -205,6 +210,23 @@ function main() {
const queues = queuesMain(board, releases, views);
views.push(library, queues);
const search = searchMain(board, releases, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
switch (parent.kind) {
case "library":
library.open();
break;
case "queues":
queues.open();
break;
case "search":
search.restore(parent.query);
break;
default:
search.showIdle();
break;
}
});
refreshQueuesBadge = () => {
void queues.refreshBadge();
};
@@ -745,6 +767,11 @@ interface ReleasesView {
parentRoute: Route,
) => void;
hide: () => void;
/**
* Called with the deck's parent route once a title is removed, so the
* surface underneath repaints instead of listing something that is gone.
*/
setRemoved: (handler: (parent: Route) => void) => void;
}
interface BucketRefs {
@@ -759,6 +786,8 @@ function releasesMain(): ReleasesView {
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const remove = must<HTMLButtonElement>("#releases-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const status = must<HTMLElement>("#releases-status");
const eligible: BucketRefs & { count: HTMLElement } = {
section: must<HTMLElement>("#bucket-eligible"),
@@ -781,6 +810,7 @@ function releasesMain(): ReleasesView {
} as const;
let current: LibraryMovie | null = null;
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let parentRoute: Route = { kind: "board" };
@@ -967,6 +997,46 @@ function releasesMain(): ReleasesView {
}, SWEEP_POLL_MS);
}
/** 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 movie = current;
if (!movie) {
return;
}
const panel = removePanel(movie, {
cancel: closeRemove,
removed: () => {
const parent = parentRoute;
clearRemove();
close();
removed?.(parent);
},
});
removeWrap.replaceChildren(panel);
removeWrap.hidden = false;
remove.setAttribute("aria-expanded", "true");
panel.querySelector<HTMLElement>(".remove-files")?.focus();
}
remove.addEventListener("click", () => {
if (removeWrap.hidden) {
openRemove();
} else {
closeRemove();
}
});
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
@@ -977,6 +1047,7 @@ function releasesMain(): ReleasesView {
container.hidden = true;
view.hidden = false;
clearBuckets();
clearRemove();
sweep.disabled = false;
back.focus();
void load();
@@ -985,6 +1056,7 @@ function releasesMain(): ReleasesView {
function hide() {
view.hidden = true;
current = null;
clearRemove();
sequence += 1;
window.clearTimeout(pollTimer);
}
@@ -1004,9 +1076,15 @@ function releasesMain(): ReleasesView {
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the deck
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
@@ -1040,7 +1118,147 @@ function releasesMain(): ReleasesView {
})();
});
return { open, hide };
return {
open,
hide,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
};
}
interface RemoveActions {
cancel: () => void;
removed: () => void;
}
/**
* The removal confirmation (issue 104). Two decisions, not one: the library
* entry always goes, the files only when asked, and the toggle starts off.
*
* 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).
*/
function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
const panel = document.createElement("div");
panel.className = "remove-body";
panel.setAttribute("role", "group");
panel.setAttribute("aria-label", `remove ${movie.title}`);
let deleteFiles = false;
let files: MovieFile[] | null = null;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "remove-files";
toggle.disabled = true;
toggle.setAttribute("aria-pressed", "false");
const toggleName = document.createElement("span");
toggleName.className = "remove-files-name";
toggleName.textContent = "delete files from disk";
const toggleWord = document.createElement("span");
toggleWord.className = "remove-files-word readout";
toggleWord.textContent = "off";
toggle.append(toggleName, toggleWord);
const evidence = document.createElement("p");
evidence.className = "remove-evidence readout dim";
evidence.textContent = "reading files…";
const path = document.createElement("p");
path.className = "remove-path readout";
path.hidden = true;
const note = document.createElement("p");
note.className = "remove-note readout";
note.id = "remove-note";
note.setAttribute("role", "status");
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "control";
confirm.setAttribute("aria-describedby", note.id);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "control";
cancel.textContent = "keep";
cancel.addEventListener("click", actions.cancel);
function paint() {
toggle.setAttribute("aria-pressed", String(deleteFiles));
toggleWord.textContent = deleteFiles ? "on" : "off";
panel.dataset.armed = String(deleteFiles);
confirm.textContent = deleteFiles ? "remove and delete files" : "remove from library";
if (deleteFiles) {
note.textContent =
"deletes the folder above. the torrent keeps seeding until its tracker rule clears.";
note.dataset.tone = "warn";
return;
}
delete note.dataset.tone;
note.textContent =
files !== null && files.length === 0
? "the library entry goes. nothing was imported for this title."
: "the library entry goes. files stay on disk.";
}
toggle.addEventListener("click", () => {
deleteFiles = !deleteFiles;
paint();
});
confirm.addEventListener("click", () => {
confirm.disabled = true;
cancel.disabled = true;
toggle.disabled = true;
delete note.dataset.tone;
note.textContent = deleteFiles ? "removing title and files…" : "removing…";
void removeMovie(movie.id, deleteFiles).then((outcome) => {
if (outcome.kind === "done") {
actions.removed();
return;
}
confirm.disabled = false;
cancel.disabled = false;
toggle.disabled = files !== null && files.length > 0;
// the row is still there on a failed unlink, so retrying is the fix
note.textContent = `remove failed — ${outcome.detail}`;
note.dataset.tone = "fault";
});
});
void movieFiles(movie.id).then((outcome) => {
if (outcome.kind === "error") {
// never offer to delete what cannot be named
evidence.textContent = `files unreadable — ${outcome.detail}`;
paint();
return;
}
files = outcome.files;
if (files.length === 0) {
evidence.textContent = "nothing on disk";
paint();
return;
}
const count = `${files.length} ${files.length === 1 ? "file" : "files"}`;
evidence.textContent = `${count} · ${formatSize(totalSize(files))}`;
const folder = libraryFolder(files);
path.hidden = false;
path.textContent = folder ?? files.map((file) => file.path).join("\n");
toggle.disabled = false;
paint();
});
const controls = document.createElement("div");
controls.className = "remove-actions";
controls.append(confirm, cancel);
paint();
// evidence, then the decision, then what the decision costs
panel.append(evidence, path, toggle, note, controls);
return panel;
}
interface ReleaseActions {