feat(api,web): remove a movie, files optional
`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:
@@ -193,7 +193,17 @@
|
||||
<span class="releases-year readout dim" id="releases-year">—</span>
|
||||
</div>
|
||||
<button type="button" class="control" id="releases-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="releases-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</header>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
|
||||
|
||||
+221
-3
@@ -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 {
|
||||
|
||||
@@ -95,8 +95,77 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string
|
||||
return `${Math.round(minutes / (24 * 60))} d ago`;
|
||||
}
|
||||
|
||||
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
|
||||
export interface MovieFile {
|
||||
id: number;
|
||||
path: string;
|
||||
size: number;
|
||||
probed: unknown;
|
||||
waiver: string | null;
|
||||
}
|
||||
|
||||
export type FilesOutcome =
|
||||
| { kind: "files"; files: MovieFile[] }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
/**
|
||||
* What this title actually put on disk. The removal confirmation names it
|
||||
* rather than promising in the abstract: the service knows only what it
|
||||
* wrote (§2), so this list is the whole truth about what a files-too remove
|
||||
* would unlink.
|
||||
*/
|
||||
export async function movieFiles(movieId: number): Promise<FilesOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}/files`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "files", files: (await response.json()) as MovieFile[] };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The §7.4 folder these files share — one folder per title, so a single path
|
||||
* 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 {
|
||||
const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/"))));
|
||||
if (folders.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
const folder = [...folders][0];
|
||||
return folder === undefined || folder === "" ? null : folder;
|
||||
}
|
||||
|
||||
export function totalSize(files: MovieFile[]): number {
|
||||
return files.reduce((sum, file) => sum + file.size, 0);
|
||||
}
|
||||
|
||||
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
|
||||
|
||||
/**
|
||||
* Remove the title from the library. `deleteFiles` is the operator's second,
|
||||
* separate decision (§7.4): the row always goes, the folder only on request.
|
||||
* Either way the torrent keeps seeding — the reaper owns that lifecycle
|
||||
* (§7.3) and a hardlinked file loses only its library name.
|
||||
*/
|
||||
export async function removeMovie(movieId: number, deleteFiles: boolean): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}?delete_files=${String(deleteFiles)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
|
||||
export async function queueSearch(movieId: number): Promise<ActionOutcome> {
|
||||
try {
|
||||
|
||||
@@ -825,6 +825,137 @@ body {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ---- removal confirmation (§7.4, issue 104) --------------------------- */
|
||||
|
||||
/* remove is not the deck's errand: it reads as a quiet control until the
|
||||
operator reaches for it, and never competes with the sweep */
|
||||
.control-quiet {
|
||||
color: var(--ink-muted);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.control-quiet:hover,
|
||||
.control-quiet[aria-expanded="true"] {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remove-panel {
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
.remove-body {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
}
|
||||
|
||||
/* armed is a state, so it is amber and it is also a word: the toggle reads
|
||||
"on" with all colour removed */
|
||||
.remove-body[data-armed="true"] {
|
||||
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||
}
|
||||
|
||||
/* a control, not a form field: it stops at its own label */
|
||||
.remove-files {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
gap: var(--space-3);
|
||||
min-height: 2.75rem;
|
||||
padding: 0 var(--space-3);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out),
|
||||
color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.remove-files:hover:not(:disabled) {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.remove-files:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.remove-files-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remove-files-word {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.remove-files[aria-pressed="true"] {
|
||||
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||
}
|
||||
|
||||
.remove-files[aria-pressed="true"] .remove-files-word {
|
||||
color: var(--signal-warn);
|
||||
}
|
||||
|
||||
.remove-evidence,
|
||||
.remove-path,
|
||||
.remove-note {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* the folder is evidence: it wraps rather than truncating, because a path
|
||||
the operator cannot read whole is not a confirmation */
|
||||
.remove-path {
|
||||
color: var(--ink-muted);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.remove-note {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.remove-note[data-tone="warn"] {
|
||||
color: var(--signal-warn);
|
||||
}
|
||||
|
||||
.remove-note[data-tone="fault"] {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
.remove-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.remove-body[data-armed="true"] .remove-actions .control:first-child {
|
||||
color: var(--signal-warn);
|
||||
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||
}
|
||||
|
||||
.remove-body[data-armed="true"] .remove-actions .control:first-child:hover:not(:disabled) {
|
||||
border-color: var(--signal-warn);
|
||||
}
|
||||
|
||||
.bucket-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user