diff --git a/apps/api/src/download_detection.rs b/apps/api/src/download_detection.rs index 1dbb241..44a59b6 100644 --- a/apps/api/src/download_detection.rs +++ b/apps/api/src/download_detection.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, Json}; +use axum::{extract::{Path, State}, Json}; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use tracing::{info, warn}; @@ -418,6 +418,82 @@ pub async fn get_latest_found( Ok(Json(libs.into_values().collect())) } +/// Delete an available download entry, or a single release within it. +/// +/// - Without `?release=N`: deletes the entire series entry. +/// - With `?release=N`: removes release at index N from the array; +/// if the array becomes empty, the entire entry is deleted. +#[utoipa::path( + delete, + path = "/available-downloads/{id}", + tag = "download_detection", + params( + ("id" = String, Path, description = "Available download ID"), + ("release" = Option, Query, description = "Release index to remove (omit to delete entire entry)"), + ), + responses( + (status = 200, description = "Deleted"), + (status = 404, description = "Not found"), + ), + security(("Bearer" = [])) +)] +pub async fn delete_available_download( + State(state): State, + Path(id): Path, + axum::extract::Query(query): axum::extract::Query, +) -> Result, ApiError> { + if let Some(release_idx) = query.release { + // Remove a single release from the JSON array + let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| ApiError::not_found("available download not found"))?; + + let releases_json: Option = row.get("available_releases"); + if let Some(serde_json::Value::Array(mut releases)) = releases_json { + if release_idx >= releases.len() { + return Err(ApiError::bad_request("release index out of bounds")); + } + releases.remove(release_idx); + + if releases.is_empty() { + sqlx::query("DELETE FROM available_downloads WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await?; + } else { + sqlx::query( + "UPDATE available_downloads SET available_releases = $1, updated_at = NOW() WHERE id = $2", + ) + .bind(serde_json::Value::Array(releases)) + .bind(id) + .execute(&state.pool) + .await?; + } + } else { + return Err(ApiError::not_found("no releases found")); + } + } else { + // Delete the entire entry + let result = sqlx::query("DELETE FROM available_downloads WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(ApiError::not_found("available download not found")); + } + } + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct DeleteAvailableQuery { + pub release: Option, +} + // --------------------------------------------------------------------------- // Background processing // --------------------------------------------------------------------------- diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index c80af8d..cbe37dd 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -162,6 +162,7 @@ async fn main() -> anyhow::Result<()> { .route("/download-detection/latest-found", get(download_detection::get_latest_found)) .route("/download-detection/:id/report", get(download_detection::get_detection_report)) .route("/download-detection/:id/results", get(download_detection::get_detection_results)) + .route("/available-downloads/:id", axum::routing::delete(download_detection::delete_available_download)) .merge(settings::settings_routes()) .route_layer(middleware::from_fn_with_state( state.clone(), diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index 27169e8..d542b56 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -161,7 +161,10 @@ fn extract_volumes_from_title(title: &str) -> Vec { while k < chars.len() && chars[k] == ' ' { k += 1; } - if let Some((n2, _)) = read_vol_prefix_number(&chars, k) { + // Try prefixed number first (T17-T23), then bare number (T17-23) + let n2_result = read_vol_prefix_number(&chars, k) + .or_else(|| read_bare_number(&chars, k)); + if let Some((n2, _)) = n2_result { if n1 < n2 && n2 - n1 <= 500 { for v in n1..=n2 { if !volumes.contains(&v) { @@ -239,6 +242,19 @@ fn extract_volumes_from_title(title: &str) -> Vec { volumes } +/// Read a bare number (no prefix) at `pos`. Returns `(number, position_after_last_digit)`. +fn read_bare_number(chars: &[char], pos: usize) -> Option<(i32, usize)> { + let mut i = pos; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + if i == pos { + return None; + } + let n: i32 = chars[pos..i].iter().collect::().parse().ok()?; + Some((n, i)) +} + /// Try to read a vol-prefixed number starting at `pos` in the `chars` slice. /// Returns `(number, position_after_last_digit)` or `None`. /// Prefixes recognised (longest first to avoid "t" matching "tome"): @@ -557,6 +573,15 @@ mod tests { assert_eq!(v, (1..=15).collect::>()); } + #[test] + fn range_dash_bare_end() { + // T17-23 (no prefix on second number) → 17..=23 + let v = sorted(extract_volumes_from_title( + "Compressé.Demon.Slayer.en.couleurs.T17-23.CBZ.Team.Chromatique", + )); + assert_eq!(v, (17..=23).collect::>()); + } + #[test] fn no_false_positive_version_string() { // v2.0 should NOT be treated as a range diff --git a/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx b/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx index 4b840ea..ba69590 100644 --- a/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx +++ b/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx @@ -198,7 +198,7 @@ export function DownloadsPage({ initialDownloads, initialLatestFound, qbConfigur
{latestFound.map(lib => ( - + refresh(false)} /> ))}
@@ -342,11 +342,23 @@ function DownloadRow({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: () ); } -function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) { +function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDto; onDeleted: () => void }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState(true); + const [deletingKey, setDeletingKey] = useState(null); const displayResults = collapsed ? lib.results.slice(0, 5) : lib.results; + async function handleDeleteRelease(seriesId: string, releaseIdx: number) { + const key = `${seriesId}-${releaseIdx}`; + setDeletingKey(key); + try { + const resp = await fetch(`/api/available-downloads/${seriesId}?release=${releaseIdx}`, { method: "DELETE" }); + if (resp.ok) onDeleted(); + } finally { + setDeletingKey(null); + } + } + return ( @@ -390,8 +402,8 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) { - {release.download_url && ( -
+
+ {release.download_url && ( -
- )} + )} + +
))} diff --git a/apps/backoffice/app/api/available-downloads/[id]/route.ts b/apps/backoffice/app/api/available-downloads/[id]/route.ts new file mode 100644 index 0000000..ece9e08 --- /dev/null +++ b/apps/backoffice/app/api/available-downloads/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const data = await apiFetch(`/available-downloads/${id}`, { method: "DELETE" }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete available download"; + return NextResponse.json({ error: message }, { status: 500 }); + } +}