diff --git a/web/index.html b/web/index.html index b49b3d4..dd45e56 100644 --- a/web/index.html +++ b/web/index.html @@ -495,6 +495,28 @@ + +
+
+

library

+
+
+ + +
+ +
+ diff --git a/web/src/main.ts b/web/src/main.ts index 8f06866..b1d6a33 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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; + /** 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; + /** 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("#series-actions"); const statusEl = must("#series-status"); const seasonsList = must("#rows-seasons"); + const remove = must("#series-remove"); + const removeWrap = must("#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(".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) ------------------------ */ diff --git a/web/src/releases.ts b/web/src/releases.ts index 1bb6480..2e74b21 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise { * 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); } diff --git a/web/src/series.ts b/web/src/series.ts index 7fb7b2d..f5dbedc 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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(); + 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 { + 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 { + 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 { + return del(`/api/episodes/${episodeId}/files`); +} + +async function del(url: string): Promise { + 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. */ diff --git a/web/src/settings.ts b/web/src/settings.ts index 2d44022..f0736d4 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -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; diff --git a/web/src/style.css b/web/src/style.css index 7791d3f..f44f7ca 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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; }