feat: suppression individuelle de releases dans les available downloads
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 39s

Ajoute DELETE /available-downloads/:id?release=N pour supprimer une
release spécifique du JSON array (supprime l'entrée série si c'est
la dernière). Bouton trash sur chaque release dans la page downloads.

Corrige aussi le parsing des ranges de volumes sans préfixe sur le
second nombre (T17-23 détecte maintenant T17 à T23).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:51:09 +01:00
parent 5e6217cc30
commit 9b04a79330
5 changed files with 146 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
use axum::{extract::State, Json}; use axum::{extract::{Path, State}, Json};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use tracing::{info, warn}; use tracing::{info, warn};
@@ -418,6 +418,82 @@ pub async fn get_latest_found(
Ok(Json(libs.into_values().collect())) 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<usize>, 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<AppState>,
Path(id): Path<Uuid>,
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>,
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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<usize>,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Background processing // Background processing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -162,6 +162,7 @@ async fn main() -> anyhow::Result<()> {
.route("/download-detection/latest-found", get(download_detection::get_latest_found)) .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/report", get(download_detection::get_detection_report))
.route("/download-detection/:id/results", get(download_detection::get_detection_results)) .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()) .merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),

View File

@@ -161,7 +161,10 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
while k < chars.len() && chars[k] == ' ' { while k < chars.len() && chars[k] == ' ' {
k += 1; 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 { if n1 < n2 && n2 - n1 <= 500 {
for v in n1..=n2 { for v in n1..=n2 {
if !volumes.contains(&v) { if !volumes.contains(&v) {
@@ -239,6 +242,19 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
volumes 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::<String>().parse().ok()?;
Some((n, i))
}
/// Try to read a vol-prefixed number starting at `pos` in the `chars` slice. /// Try to read a vol-prefixed number starting at `pos` in the `chars` slice.
/// Returns `(number, position_after_last_digit)` or `None`. /// Returns `(number, position_after_last_digit)` or `None`.
/// Prefixes recognised (longest first to avoid "t" matching "tome"): /// Prefixes recognised (longest first to avoid "t" matching "tome"):
@@ -557,6 +573,15 @@ mod tests {
assert_eq!(v, (1..=15).collect::<Vec<_>>()); assert_eq!(v, (1..=15).collect::<Vec<_>>());
} }
#[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::<Vec<_>>());
}
#[test] #[test]
fn no_false_positive_version_string() { fn no_false_positive_version_string() {
// v2.0 should NOT be treated as a range // v2.0 should NOT be treated as a range

View File

@@ -198,7 +198,7 @@ export function DownloadsPage({ initialDownloads, initialLatestFound, qbConfigur
</h2> </h2>
<div className="space-y-6"> <div className="space-y-6">
{latestFound.map(lib => ( {latestFound.map(lib => (
<AvailableLibraryCard key={lib.library_id} lib={lib} /> <AvailableLibraryCard key={lib.library_id} lib={lib} onDeleted={() => refresh(false)} />
))} ))}
</div> </div>
</div> </div>
@@ -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 { t } = useTranslation();
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const displayResults = collapsed ? lib.results.slice(0, 5) : lib.results; 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 ( return (
<Card> <Card>
<CardHeader className="pb-3 px-3 sm:px-6"> <CardHeader className="pb-3 px-3 sm:px-6">
@@ -390,8 +402,8 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 self-end sm:self-auto shrink-0">
{release.download_url && ( {release.download_url && (
<div className="self-end sm:self-auto shrink-0">
<QbittorrentDownloadButton <QbittorrentDownloadButton
downloadUrl={release.download_url} downloadUrl={release.download_url}
releaseId={`${r.id}-${idx}`} releaseId={`${r.id}-${idx}`}
@@ -400,8 +412,19 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
expectedVolumes={release.matched_missing_volumes} expectedVolumes={release.matched_missing_volumes}
allVolumes={release.all_volumes} allVolumes={release.all_volumes}
/> />
</div>
)} )}
<button
type="button"
onClick={() => handleDeleteRelease(r.id, idx)}
disabled={deletingKey === `${r.id}-${idx}`}
className="inline-flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30"
title={t("downloads.delete")}
>
{deletingKey === `${r.id}-${idx}`
? <Icon name="spinner" size="sm" className="animate-spin" />
: <Icon name="trash" size="sm" />}
</button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -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 });
}
}