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:
Miguel Palhas
2026-08-24 17:09:46 +01:00
parent 3dda666273
commit 7c30928907
6 changed files with 275 additions and 27 deletions
+62
View File
@@ -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. */