feat: suppression individuelle de releases dans les available downloads
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 39s
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:
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -161,7 +161,10 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
||||
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<i32> {
|
||||
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.
|
||||
/// 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::<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]
|
||||
fn no_false_positive_version_string() {
|
||||
// v2.0 should NOT be treated as a range
|
||||
|
||||
@@ -198,7 +198,7 @@ export function DownloadsPage({ initialDownloads, initialLatestFound, qbConfigur
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{latestFound.map(lib => (
|
||||
<AvailableLibraryCard key={lib.library_id} lib={lib} />
|
||||
<AvailableLibraryCard key={lib.library_id} lib={lib} onDeleted={() => refresh(false)} />
|
||||
))}
|
||||
</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 [collapsed, setCollapsed] = useState(true);
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 px-3 sm:px-6">
|
||||
@@ -390,8 +402,8 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 self-end sm:self-auto shrink-0">
|
||||
{release.download_url && (
|
||||
<div className="self-end sm:self-auto shrink-0">
|
||||
<QbittorrentDownloadButton
|
||||
downloadUrl={release.download_url}
|
||||
releaseId={`${r.id}-${idx}`}
|
||||
@@ -400,8 +412,19 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
|
||||
expectedVolumes={release.matched_missing_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>
|
||||
|
||||
13
apps/backoffice/app/api/available-downloads/[id]/route.ts
Normal file
13
apps/backoffice/app/api/available-downloads/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user