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>
|
</div>
|
||||||
</section>
|
</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>
|
<ul class="deck-rows seasons" id="rows-seasons"></ul>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+175
-23
@@ -32,6 +32,7 @@ import {
|
|||||||
type SeriesAttention,
|
type SeriesAttention,
|
||||||
} from "./queues";
|
} from "./queues";
|
||||||
import {
|
import {
|
||||||
|
type ActionOutcome,
|
||||||
bucketOf,
|
bucketOf,
|
||||||
type FilesOutcome,
|
type FilesOutcome,
|
||||||
formatAudio,
|
formatAudio,
|
||||||
@@ -44,7 +45,6 @@ import {
|
|||||||
formatSweepAge,
|
formatSweepAge,
|
||||||
grabRelease,
|
grabRelease,
|
||||||
libraryFolder,
|
libraryFolder,
|
||||||
type MovieFile,
|
|
||||||
type MovieRelease,
|
type MovieRelease,
|
||||||
movieFiles,
|
movieFiles,
|
||||||
movieReleases,
|
movieReleases,
|
||||||
@@ -87,16 +87,20 @@ import {
|
|||||||
fileAttributeTags,
|
fileAttributeTags,
|
||||||
formatAirDate,
|
formatAirDate,
|
||||||
isUnaired,
|
isUnaired,
|
||||||
|
removeEpisodeFiles,
|
||||||
|
removeSeasonFiles,
|
||||||
|
removeSeries,
|
||||||
type SeriesMetadata,
|
type SeriesMetadata,
|
||||||
seasonCounts,
|
seasonCounts,
|
||||||
seasonTarget,
|
seasonTarget,
|
||||||
|
seriesFolder,
|
||||||
seriesMetadata,
|
seriesMetadata,
|
||||||
setEpisodeWanted,
|
setEpisodeWanted,
|
||||||
setSeasonTracked,
|
setSeasonTracked,
|
||||||
type TvTarget,
|
type TvTarget,
|
||||||
waiveAndGrabTv,
|
waiveAndGrabTv,
|
||||||
} from "./series";
|
} from "./series";
|
||||||
import { settingsMain } from "./settings";
|
import { armedDelete, settingsMain } from "./settings";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
const POLL_MS = 15_000;
|
const POLL_MS = 15_000;
|
||||||
@@ -257,7 +261,7 @@ function main() {
|
|||||||
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
||||||
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
||||||
// a removed title must not survive on the surface the page opened over
|
// a removed title must not survive on the surface the page opened over
|
||||||
movieDetail.setRemoved((parent) => {
|
const openAfterRemoval = (parent: Route) => {
|
||||||
switch (parent.kind) {
|
switch (parent.kind) {
|
||||||
case "library":
|
case "library":
|
||||||
library.open();
|
library.open();
|
||||||
@@ -272,7 +276,9 @@ function main() {
|
|||||||
library.open();
|
library.open();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
movieDetail.setRemoved(openAfterRemoval);
|
||||||
|
seriesDetail.setRemoved(openAfterRemoval);
|
||||||
refreshQueuesBadge = () => {
|
refreshQueuesBadge = () => {
|
||||||
void queues.refreshBadge();
|
void queues.refreshBadge();
|
||||||
};
|
};
|
||||||
@@ -1775,15 +1781,24 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
if (!movie) {
|
if (!movie) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const panel = removePanel(movie, {
|
const panel = removePanel(
|
||||||
cancel: closeRemove,
|
{
|
||||||
removed: () => {
|
title: movie.title,
|
||||||
const parent = parentRoute;
|
files: () => movieFiles(movie.id),
|
||||||
clearRemove();
|
folder: libraryFolder,
|
||||||
close();
|
remove: () => removeMovie(movie.id),
|
||||||
removed?.(parent);
|
seedLine: "the torrent keeps seeding until its tracker rule clears.",
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
cancel: closeRemove,
|
||||||
|
removed: () => {
|
||||||
|
const parent = parentRoute;
|
||||||
|
clearRemove();
|
||||||
|
close();
|
||||||
|
removed?.(parent);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
removeWrap.replaceChildren(panel);
|
removeWrap.replaceChildren(panel);
|
||||||
removeWrap.hidden = false;
|
removeWrap.hidden = false;
|
||||||
remove.setAttribute("aria-expanded", "true");
|
remove.setAttribute("aria-expanded", "true");
|
||||||
@@ -1936,6 +1951,27 @@ interface RemoveActions {
|
|||||||
removed: () => void;
|
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
|
* The removal confirmation (issue 104, simplified by 110): removing a title
|
||||||
* always unlinks its §7.4 folder, so there is one decision, not two.
|
* 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
|
* 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
|
* 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 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");
|
const panel = document.createElement("div");
|
||||||
panel.className = "remove-body";
|
panel.className = "remove-body";
|
||||||
panel.setAttribute("role", "group");
|
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");
|
const evidence = document.createElement("p");
|
||||||
evidence.className = "remove-evidence readout dim";
|
evidence.className = "remove-evidence readout dim";
|
||||||
@@ -1982,8 +2019,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
|||||||
const hasFiles = files !== null && files.length > 0;
|
const hasFiles = files !== null && files.length > 0;
|
||||||
panel.dataset.armed = String(hasFiles);
|
panel.dataset.armed = String(hasFiles);
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
note.textContent =
|
note.textContent = `${folderNamed ? "deletes the folder above." : "deletes the files above."} ${subject.seedLine}`;
|
||||||
"deletes the folder above. the torrent keeps seeding until its tracker rule clears.";
|
|
||||||
note.dataset.tone = "warn";
|
note.dataset.tone = "warn";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1996,7 +2032,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
|||||||
cancel.disabled = true;
|
cancel.disabled = true;
|
||||||
delete note.dataset.tone;
|
delete note.dataset.tone;
|
||||||
note.textContent = "removing title and files…";
|
note.textContent = "removing title and files…";
|
||||||
void removeMovie(movie.id).then((outcome) => {
|
void subject.remove().then((outcome) => {
|
||||||
if (outcome.kind === "done") {
|
if (outcome.kind === "done") {
|
||||||
actions.removed();
|
actions.removed();
|
||||||
return;
|
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") {
|
if (outcome.kind === "error") {
|
||||||
evidence.textContent = `files unreadable — ${outcome.detail}`;
|
evidence.textContent = `files unreadable — ${outcome.detail}`;
|
||||||
paint();
|
paint();
|
||||||
@@ -2023,7 +2059,8 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
|||||||
}
|
}
|
||||||
const count = `${files.length} ${files.length === 1 ? "file" : "files"}`;
|
const count = `${files.length} ${files.length === 1 ? "file" : "files"}`;
|
||||||
evidence.textContent = `${count} · ${formatSize(totalSize(files))}`;
|
evidence.textContent = `${count} · ${formatSize(totalSize(files))}`;
|
||||||
const folder = libraryFolder(files);
|
const folder = subject.folder(files);
|
||||||
|
folderNamed = folder !== null;
|
||||||
path.hidden = false;
|
path.hidden = false;
|
||||||
path.textContent = folder ?? files.map((file) => file.path).join("\n");
|
path.textContent = folder ?? files.map((file) => file.path).join("\n");
|
||||||
paint();
|
paint();
|
||||||
@@ -2928,6 +2965,8 @@ interface SeriesView {
|
|||||||
returnTo: HTMLElement,
|
returnTo: HTMLElement,
|
||||||
parentRoute: Route,
|
parentRoute: Route,
|
||||||
) => Promise<void>;
|
) => 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");
|
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 actionsEl = must<HTMLElement>("#series-actions");
|
||||||
const statusEl = must<HTMLElement>("#series-status");
|
const statusEl = must<HTMLElement>("#series-status");
|
||||||
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
||||||
|
const remove = must<HTMLButtonElement>("#series-remove");
|
||||||
|
const removeWrap = must<HTMLElement>("#series-remove-panel");
|
||||||
|
|
||||||
let roots: Root[] = [];
|
let roots: Root[] = [];
|
||||||
let series: ApiSeries | null = null;
|
let series: ApiSeries | null = null;
|
||||||
@@ -2963,6 +3004,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
let focusKey: string | null = null;
|
let focusKey: string | null = null;
|
||||||
// guards a stale fetch from painting over a newer view
|
// guards a stale fetch from painting over a newer view
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
|
let removed: ((parent: Route) => void) | null = null;
|
||||||
|
|
||||||
function setStatus(text: string | null, tone?: "fault") {
|
function setStatus(text: string | null, tone?: "fault") {
|
||||||
statusEl.hidden = text === null;
|
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() {
|
function paintHeader() {
|
||||||
const current = series;
|
const current = series;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -3200,6 +3295,32 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
line.append(space, deckBtn);
|
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);
|
item.append(line);
|
||||||
|
|
||||||
const episodes = document.createElement("ul");
|
const episodes = document.createElement("ul");
|
||||||
@@ -3262,7 +3383,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
const onDisk = episode.state === "available";
|
const onDisk = episode.state === "available";
|
||||||
|
|
||||||
if (aired && onDisk) {
|
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) {
|
} else if (!aired) {
|
||||||
const note = document.createElement("span");
|
const note = document.createElement("span");
|
||||||
note.className = "readout dim ep-unaired";
|
note.className = "readout dim ep-unaired";
|
||||||
@@ -3392,6 +3531,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
focusKey = null;
|
focusKey = null;
|
||||||
deckEl.hidden = true;
|
deckEl.hidden = true;
|
||||||
view.hidden = false;
|
view.hidden = false;
|
||||||
|
clearRemove();
|
||||||
clearRichDetail();
|
clearRichDetail();
|
||||||
back.focus();
|
back.focus();
|
||||||
await load();
|
await load();
|
||||||
@@ -3402,6 +3542,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
seriesId = null;
|
seriesId = null;
|
||||||
seasons = null;
|
seasons = null;
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
|
clearRemove();
|
||||||
clearRichDetail();
|
clearRichDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3421,6 +3562,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
(event) => {
|
(event) => {
|
||||||
if (event.key === "Escape" && !view.hidden) {
|
if (event.key === "Escape" && !view.hidden) {
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
|
// the confirmation is the innermost layer: Esc abandons it first
|
||||||
|
if (!removeWrap.hidden) {
|
||||||
|
closeRemove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate(parentRoute);
|
navigate(parentRoute);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@@ -3428,7 +3574,13 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { hide, open };
|
return {
|
||||||
|
hide,
|
||||||
|
open,
|
||||||
|
setRemoved: (handler: (parent: Route) => void) => {
|
||||||
|
removed = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
|
/* ---- 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,
|
* names everything a files-too remove takes. `null` when the files disagree,
|
||||||
* which the panel then says instead of naming one folder falsely.
|
* 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("/"))));
|
const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/"))));
|
||||||
if (folders.size !== 1) {
|
if (folders.size !== 1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -140,7 +140,7 @@ export function libraryFolder(files: MovieFile[]): string | null {
|
|||||||
return folder === undefined || folder === "" ? null : folder;
|
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);
|
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 ---------------------------------------- */
|
/* ---- manual triggers and decks ---------------------------------------- */
|
||||||
|
|
||||||
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
|
/** §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
|
* First click arms the destructive action, second confirms. The armed state
|
||||||
* clears on blur or after a few seconds, so an accidental double click
|
* 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);
|
const button = el("button", "control control-quiet readout", label);
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
|||||||
@@ -995,6 +995,17 @@ body {
|
|||||||
border-color: var(--signal-warn);
|
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 {
|
.bucket-head {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user