diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index e94ad6e..720f4a6 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -103,7 +103,7 @@ async fn main() -> anyhow::Result<()> { .route("/libraries/:id/reading-status-provider", axum::routing::patch(libraries::update_reading_status_provider)) .route("/books/:id", axum::routing::patch(books::update_book).delete(books::delete_book)) .route("/books/:id/convert", axum::routing::post(books::convert_book)) - .route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series)) + .route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series).delete(series::delete_series)) .route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild)) .route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild)) .route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate)) diff --git a/apps/api/src/series.rs b/apps/api/src/series.rs index f9395d3..0151aa1 100644 --- a/apps/api/src/series.rs +++ b/apps/api/src/series.rs @@ -1076,3 +1076,133 @@ pub async fn update_series( Ok(Json(UpdateSeriesResponse { updated: result.rows_affected() })) } + +/// Delete an entire series: removes all books (files + DB), the series folder, +/// and all related metadata (external links, anilist, available downloads). +#[utoipa::path( + delete, + path = "/libraries/{library_id}/series/{name}", + tag = "series", + params( + ("library_id" = String, Path, description = "Library UUID"), + ("name" = String, Path, description = "Series name (URL-encoded)"), + ), + responses( + (status = 200, description = "Series deleted"), + (status = 404, description = "Series not found"), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn delete_series( + State(state): State, + Extension(user): Extension, + Path((library_id, name)): Path<(Uuid, String)>, +) -> Result, ApiError> { + use stripstream_core::paths::remap_libraries_path; + + // Find all books in this series + let book_rows = sqlx::query( + "SELECT b.id, b.thumbnail_path, bf.abs_path \ + FROM books b \ + LEFT JOIN book_files bf ON bf.book_id = b.id \ + WHERE b.library_id = $1 AND LOWER(COALESCE(NULLIF(b.series, ''), 'unclassified')) = LOWER($2)", + ) + .bind(library_id) + .bind(&name) + .fetch_all(&state.pool) + .await?; + + if book_rows.is_empty() { + return Err(ApiError::not_found("series not found or has no books")); + } + + // Collect the series directory from the first book's path + let mut series_dir: Option = None; + + // Delete each book's physical file and thumbnail + for row in &book_rows { + let abs_path: Option = row.get("abs_path"); + let thumbnail_path: Option = row.get("thumbnail_path"); + + if let Some(ref path) = abs_path { + let physical = remap_libraries_path(path); + if series_dir.is_none() { + if let Some(parent) = std::path::Path::new(&physical).parent() { + series_dir = Some(parent.to_string_lossy().into_owned()); + } + } + match std::fs::remove_file(&physical) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + tracing::warn!("[SERIES] Failed to delete file {}: {}", physical, e); + } + } + } + + if let Some(ref path) = thumbnail_path { + let _ = std::fs::remove_file(path); + } + } + + // Delete the series directory if it's now empty (or only has non-book files) + if let Some(ref dir) = series_dir { + match std::fs::remove_dir_all(dir) { + Ok(()) => tracing::info!("[SERIES] Deleted series directory: {}", dir), + Err(e) => tracing::warn!("[SERIES] Failed to delete series directory {}: {}", dir, e), + } + } + + // Delete all books from DB (cascades to book_files, reading_progress, etc.) + let book_ids: Vec = book_rows.iter().map(|r| r.get("id")).collect(); + sqlx::query("DELETE FROM books WHERE id = ANY($1)") + .bind(&book_ids) + .execute(&state.pool) + .await?; + + // Delete series metadata + sqlx::query("DELETE FROM series_metadata WHERE library_id = $1 AND name = $2") + .bind(library_id) + .bind(&name) + .execute(&state.pool) + .await?; + + // Delete external metadata links (cascades to external_book_metadata) + sqlx::query("DELETE FROM external_metadata_links WHERE library_id = $1 AND LOWER(series_name) = LOWER($2)") + .bind(library_id) + .bind(&name) + .execute(&state.pool) + .await?; + + // Delete anilist link + let _ = sqlx::query("DELETE FROM anilist_series_links WHERE library_id = $1 AND LOWER(series_name) = LOWER($2)") + .bind(library_id) + .bind(&name) + .execute(&state.pool) + .await; + + // Delete available downloads + let _ = sqlx::query("DELETE FROM available_downloads WHERE library_id = $1 AND LOWER(series_name) = LOWER($2)") + .bind(library_id) + .bind(&name) + .execute(&state.pool) + .await; + + // Queue a scan job for consistency + let scan_job_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'scan', 'pending')", + ) + .bind(scan_job_id) + .bind(library_id) + .execute(&state.pool) + .await?; + + tracing::info!( + "[SERIES] Deleted series '{}' ({} books) from library {}, scan job {} queued", + name, book_ids.len(), library_id, scan_job_id + ); + + Ok(Json(crate::responses::DeletedResponse::new(library_id))) +} diff --git a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx index c0b87b3..23d030e 100644 --- a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx +++ b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx @@ -21,6 +21,9 @@ const ReadingStatusModal = nextDynamic( const ProwlarrSearchModal = nextDynamic( () => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal) ); +const DeleteSeriesButton = nextDynamic( + () => import("@/app/components/DeleteSeriesButton").then(m => m.DeleteSeriesButton) +); import { notFound } from "next/navigation"; import { getServerTranslations } from "@/lib/i18n/server"; @@ -262,6 +265,10 @@ export default async function SeriesDetailPage({ readingStatusProvider={library.reading_status_provider ?? null} existingLink={readingStatusLink} /> + diff --git a/apps/backoffice/app/components/DeleteSeriesButton.tsx b/apps/backoffice/app/components/DeleteSeriesButton.tsx new file mode 100644 index 0000000..8b4d3a9 --- /dev/null +++ b/apps/backoffice/app/components/DeleteSeriesButton.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Icon, Modal } from "./ui"; +import { useTranslation } from "@/lib/i18n/context"; + +export function DeleteSeriesButton({ libraryId, seriesName }: { libraryId: string; seriesName: string }) { + const { t } = useTranslation(); + const router = useRouter(); + const [showConfirm, setShowConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); + + async function handleDelete() { + setDeleting(true); + setShowConfirm(false); + try { + const resp = await fetch( + `/api/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`, + { method: "DELETE" } + ); + if (resp.ok) { + router.push(`/libraries/${libraryId}/series`); + } + } finally { + setDeleting(false); + } + } + + return ( + <> + + + setShowConfirm(false)} maxWidth="sm"> +
+

+ {t("seriesDetail.delete")} +

+

+ {t("seriesDetail.confirmDelete")} +

+
+
+ + +
+
+ + ); +} diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index d19c9de..414ea32 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -764,6 +764,8 @@ const en: Record = { "bookDetail.updatedAt": "Updated", "bookDetail.delete": "Delete", "bookDetail.confirmDelete": "The file will be permanently deleted from disk. This action cannot be undone.", + "seriesDetail.delete": "Delete series", + "seriesDetail.confirmDelete": "All books and the series folder will be permanently deleted from disk. This action cannot be undone.", // Book preview "bookPreview.preview": "Preview", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 1de57fc..f455473 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -762,6 +762,8 @@ const fr = { "bookDetail.updatedAt": "Mis à jour", "bookDetail.delete": "Supprimer", "bookDetail.confirmDelete": "Le fichier sera définitivement supprimé du disque. Cette action est irréversible.", + "seriesDetail.delete": "Supprimer la série", + "seriesDetail.confirmDelete": "Tous les livres et le dossier de la série seront définitivement supprimés du disque. Cette action est irréversible.", // Book preview "bookPreview.preview": "Aperçu",