From 6376fb549a3a907cf50f4577c1f20dbd6eaa28de Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 08:49:47 +0000 Subject: [PATCH 1/8] fix: support mode=addurl over GET so category is honored (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NZB360 (and real SABnzbd) add search results by issuing a plain GET request with mode=addurl, since there's no file body to upload — only the multipart POST path applied the cat/priority/password overrides for that mode, so GET requests silently fell through to the "Unknown mode" branch and any requested category was dropped. Extract the URL-fetch-and-enqueue logic into a shared handle_addurl() used by both the GET and POST entry points. --- crates/nzb-web/src/sabnzbd_compat.rs | 286 ++++++++++++++++++--------- 1 file changed, 193 insertions(+), 93 deletions(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..97dd982 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -73,10 +73,132 @@ pub async fn h_sabnzbd_api_get( } let mode = req.mode.as_deref().unwrap_or(""); + + // `addurl` fetches a remote NZB and has no file body to upload, so real + // SABnzbd (and clients like NZB360/Sonarr/Radarr) issue it as a plain + // GET rather than a multipart POST. Route it to the same URL-fetching + // logic the POST handler uses so `cat`/`priority` are honored here too. + if mode == "addurl" { + let url = req.name.clone().or_else(|| req.value.clone()); + return handle_addurl( + &state, + url, + req.name.clone(), + req.cat.clone(), + req.priority.clone(), + req.password.clone(), + ) + .await; + } + let result = dispatch_mode(&state, mode, &req); Ok(result) } +/// Fetch an NZB from a URL and enqueue it, applying category/priority/password +/// overrides. Shared by the GET and POST `addurl` entry points. +async fn handle_addurl( + state: &AppState, + url: Option, + name: Option, + cat: Option, + priority: Option, + password: Option, +) -> Result, ApiError> { + let url = url.unwrap_or_default(); + + if url.is_empty() { + return Ok(Json(serde_json::json!({ + "status": false, + "error": "No URL provided" + }))); + } + + tracing::info!(url = %url, "Fetching NZB from URL via arr API"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ApiError::from(anyhow::anyhow!("HTTP client error: {e}")))?; + + let response = client + .get(&url) + .send() + .await + .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to fetch URL: {e}")))?; + + if !response.status().is_success() { + return Ok(Json(serde_json::json!({ + "status": false, + "error": format!("URL returned HTTP {}", response.status()) + }))); + } + + let data = response + .bytes() + .await + .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to read response: {e}")))?; + + // Derive job name from URL filename if not provided + let job_name = name.unwrap_or_else(|| { + url.rsplit('/') + .next() + .and_then(|s| s.split('?').next()) + .unwrap_or("unknown") + .strip_suffix(".nzb") + .unwrap_or( + url.rsplit('/') + .next() + .and_then(|s| s.split('?').next()) + .unwrap_or("unknown"), + ) + .to_string() + }); + + match nzb_parser::parse_nzb(&job_name, &data) { + Ok(mut job) => { + if let Some(ref c) = cat + && !c.is_empty() + { + job.category = c.clone(); + } + if let Some(ref p) = priority { + job.priority = sab_priority_to_priority(p); + } + + // API-provided password overrides NZB metadata password + if let Some(ref pw) = password { + job.password = Some(pw.clone()); + } + + let qm = &state.queue_manager; + job.work_dir = qm.incomplete_dir().join(&job.id); + job.output_dir = qm.complete_dir().join(&job.category).join(&job.name); + + let nzo_id = format!("SABnzbd_nzo_{}", &job.id[..12.min(job.id.len())]); + + tracing::info!( + name = %job.name, + id = %job.id, + files = job.file_count, + "NZB added to queue via URL (arr API)" + ); + + let nzb_bytes = data.to_vec(); + qm.add_job(job, Some(nzb_bytes)).map_err(ApiError::from)?; + + Ok(Json(serde_json::json!({ + "status": true, + "nzo_ids": [nzo_id] + }))) + } + Err(e) => Ok(Json(serde_json::json!({ + "status": false, + "error": format!("Failed to parse NZB: {e}") + }))), + } +} + /// POST /sabnzbd/api -- Handle POST requests (addfile multipart, or form-encoded). pub async fn h_sabnzbd_api_post( State(state): State>, @@ -238,99 +360,8 @@ pub async fn h_sabnzbd_api_post( } "addurl" => { - let url = nzb_url.or(name.clone()).unwrap_or_default(); - - if url.is_empty() { - return Ok(Json(serde_json::json!({ - "status": false, - "error": "No URL provided" - }))); - } - - tracing::info!(url = %url, "Fetching NZB from URL via arr API"); - - // Fetch the NZB from the URL - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ApiError::from(anyhow::anyhow!("HTTP client error: {e}")))?; - - let response = client - .get(&url) - .send() - .await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to fetch URL: {e}")))?; - - if !response.status().is_success() { - return Ok(Json(serde_json::json!({ - "status": false, - "error": format!("URL returned HTTP {}", response.status()) - }))); - } - - let data = response - .bytes() - .await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to read response: {e}")))?; - - // Derive job name from URL filename if not provided - let job_name = name.clone().unwrap_or_else(|| { - url.rsplit('/') - .next() - .and_then(|s| s.split('?').next()) - .unwrap_or("unknown") - .strip_suffix(".nzb") - .unwrap_or( - url.rsplit('/') - .next() - .and_then(|s| s.split('?').next()) - .unwrap_or("unknown"), - ) - .to_string() - }); - - match nzb_parser::parse_nzb(&job_name, &data) { - Ok(mut job) => { - if let Some(ref c) = cat - && !c.is_empty() - { - job.category = c.clone(); - } - if let Some(ref p) = priority { - job.priority = sab_priority_to_priority(p); - } - - // API-provided password overrides NZB metadata password - if let Some(ref pw) = password { - job.password = Some(pw.clone()); - } - - let qm = &state.queue_manager; - job.work_dir = qm.incomplete_dir().join(&job.id); - job.output_dir = qm.complete_dir().join(&job.category).join(&job.name); - - let nzo_id = format!("SABnzbd_nzo_{}", &job.id[..12.min(job.id.len())]); - - tracing::info!( - name = %job.name, - id = %job.id, - files = job.file_count, - "NZB added to queue via URL (arr API)" - ); - - let nzb_bytes = data.to_vec(); - qm.add_job(job, Some(nzb_bytes)).map_err(ApiError::from)?; - - Ok(Json(serde_json::json!({ - "status": true, - "nzo_ids": [nzo_id] - }))) - } - Err(e) => Ok(Json(serde_json::json!({ - "status": false, - "error": format!("Failed to parse NZB: {e}") - }))), - } + let url = nzb_url.or_else(|| name.clone()); + handle_addurl(&state, url, name, cat, priority, password).await } _ => { @@ -2148,4 +2179,73 @@ mod tests { sab_contract::golden(&contents); } } + + const SAMPLE_NZB: &str = r#" + + + alt.binaries.test + + article1@example.com + article2@example.com + + +"#; + + /// Serves `body` once over a raw TCP listener bound to an ephemeral + /// port, returning the URL to fetch it from. + async fn spawn_nzb_server(body: &'static str) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral test server"); + let addr = listener.local_addr().expect("test server local addr"); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept test connection"); + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-nzb\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + }); + + format!("http://{addr}/test.nzb") + } + + /// NZB360 (and real SABnzbd) add downloads found via search as a plain + /// GET `mode=addurl` request, since there's no file body to upload -- + /// only the POST/multipart path handled `cat` for that mode, so GET + /// requests silently dropped the requested category. + #[tokio::test] + async fn addurl_over_get_applies_requested_category() { + let test_state = test_state(); + let url = spawn_nzb_server(SAMPLE_NZB).await; + + let req = SabApiRequest { + mode: Some("addurl".into()), + name: Some(url), + cat: Some("movies".into()), + apikey: Some("contract-api-key".into()), + ..SabApiRequest::default() + }; + + let response = h_sabnzbd_api_get(State(Arc::new(test_state.state)), Query(req)) + .await + .expect("addurl over GET should succeed") + .into_response(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON body"); + + assert_eq!(value["status"], serde_json::json!(true)); + assert!(value["nzo_ids"][0].as_str().is_some()); + } } From 50c2753426664c1b9cabd0d495d7070b7933b86c Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:16:05 +0000 Subject: [PATCH 2/8] fix: correct SABnzbd priority numeric mapping (#71) sab_priority_to_priority mapped 0/1/2/-100/3 to Low/Normal/High/Force, shifted by one from SABnzbd's real numeric codes (constants.py: Low=-1, Normal=0, High=1, Force=2, Default=-100, Repair=3) and contradicting sab_priority_matches, which already used the correct table for queue filtering. Any client setting priority via addfile/addurl/priority-change got the wrong priority applied. --- crates/nzb-web/src/sabnzbd_compat.rs | 40 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..a6f26a9 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -1230,10 +1230,13 @@ fn handle_rename(state: &AppState, req: &SabApiRequest) -> Json Priority { match s.trim() { - "-100" | "3" => Priority::Force, - "2" => Priority::High, - "1" => Priority::Normal, - "0" => Priority::Low, + "-1" => Priority::Low, + "0" | "-100" => Priority::Normal, + "1" => Priority::High, + // SABnzbd's Force (2) and Repair (3) priorities both mean "jump the + // queue"; RustNZB has no separate Repair concept, so both map to + // our highest priority. + "2" | "3" => Priority::Force, _ => Priority::Normal, } } @@ -2148,4 +2151,33 @@ mod tests { sab_contract::golden(&contents); } } + + /// Numeric priority codes per SABnzbd 5.1.x `sabnzbd/constants.py`: + /// FORCE_PRIORITY=2, HIGH_PRIORITY=1, NORMAL_PRIORITY=0, LOW_PRIORITY=-1, + /// DEFAULT_PRIORITY=-100 (displayed/treated as Normal), REPAIR_PRIORITY=3. + #[test] + fn sab_priority_to_priority_matches_upstream_numeric_codes() { + assert_eq!(sab_priority_to_priority("-1"), Priority::Low); + assert_eq!(sab_priority_to_priority("0"), Priority::Normal); + assert_eq!(sab_priority_to_priority("1"), Priority::High); + assert_eq!(sab_priority_to_priority("2"), Priority::Force); + assert_eq!(sab_priority_to_priority("3"), Priority::Force); + assert_eq!(sab_priority_to_priority("-100"), Priority::Normal); + } + + /// The numeric codes accepted when *setting* a priority must agree with + /// the codes `sab_priority_matches` uses when *filtering* the queue by + /// priority -- a prior regression let these two tables diverge silently. + #[test] + fn sab_priority_to_priority_agrees_with_sab_priority_matches() { + for (priority, numeric) in [ + (Priority::Low, "-1"), + (Priority::Normal, "0"), + (Priority::High, "1"), + (Priority::Force, "2"), + ] { + assert_eq!(sab_priority_to_priority(numeric), priority); + assert!(sab_priority_matches(priority, numeric)); + } + } } From ebb2ec28b7cacc83899647f1e65158af0eeff624 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:19:41 +0000 Subject: [PATCH 3/8] fix: route mode=queue&name=priority/rename/purge (#72) Real SABnzbd has no top-level mode=priority or mode=rename -- those are sub-commands of mode=queue, dispatched via the name parameter (_api_queue_table: delete, rename, priority, purge, pause, resume, change_complete_action, ...). handle_queue only recognized delete/pause/resume, so a compliant client's real priority-change or rename request silently fell through to a plain queue listing. Adds routing for name=priority, name=rename, name=purge, and a change_complete_action no-op, alongside the existing (non-standard but harmless) top-level mode=priority/mode=rename aliases. --- crates/nzb-web/src/sabnzbd_compat.rs | 152 ++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..547647d 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -493,11 +493,18 @@ fn handle_fullstatus(state: &AppState) -> Json { fn handle_queue(state: &AppState, req: &SabApiRequest) -> Json { let qm = &state.queue_manager; - // Sub-commands: mode=queue&name=delete|pause|resume&value=nzo_ID + // Sub-commands dispatched via mode=queue&name=, matching SABnzbd's + // real `_api_queue_table` (delete, pause, resume, priority, rename, + // purge, change_complete_action). `sort` and `delete_nzf` have no + // equivalent capability in RustNZB's queue manager yet. match req.name.as_deref() { Some("delete") => return handle_queue_delete(state, req), Some("pause") => return handle_queue_item_pause(state, req), Some("resume") => return handle_queue_item_resume(state, req), + Some("priority") => return handle_queue_priority(state, req), + Some("rename") => return handle_queue_rename(state, req), + Some("purge") => return handle_queue_purge(state), + Some("change_complete_action") => return Json(serde_json::json!({ "status": true })), _ => {} } @@ -750,6 +757,62 @@ fn handle_queue_item_resume(state: &AppState, req: &SabApiRequest) -> Json Json { + let target = req.value.as_deref().unwrap_or(""); + let priority = req.value2.as_deref().unwrap_or(""); + if target.is_empty() || priority.is_empty() { + return Json(serde_json::json!({ + "status": false, + "error": "Missing value (job id) or value2 (priority)" + })); + } + + let priority_value = sab_priority_to_priority(priority); + let qm = &state.queue_manager; + let mut applied = false; + for raw_id in target.split(',').map(str::trim).filter(|id| !id.is_empty()) { + let id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); + if qm.set_job_priority(id, priority_value).is_ok() { + applied = true; + } + } + + Json(serde_json::json!({ "status": applied })) +} + +/// Handle mode=queue&name=rename&value=nzo_id&value2=new_name. +fn handle_queue_rename(state: &AppState, req: &SabApiRequest) -> Json { + let target = req.value.as_deref().unwrap_or(""); + let new_name = req.value2.as_deref().unwrap_or(""); + if target.is_empty() || new_name.is_empty() { + return Json(serde_json::json!({ + "status": false, + "error": "Missing value (job id) or value2 (new name)" + })); + } + + let id = target.strip_prefix("SABnzbd_nzo_").unwrap_or(target); + match state.queue_manager.rename_job(id, new_name) { + Ok(()) => Json(serde_json::json!({ "status": true })), + Err(error) => Json(serde_json::json!({ "status": false, "error": error.to_string() })), + } +} + +/// Handle mode=queue&name=purge (remove every queued job). +fn handle_queue_purge(state: &AppState) -> Json { + let qm = &state.queue_manager; + let jobs = qm.get_jobs(); + let nzo_ids: Vec = jobs.iter().map(queue_nzo_id).collect(); + for job in &jobs { + let _ = qm.remove_job(&job.id); + } + tracing::info!(count = nzo_ids.len(), "Queue purged via arr API"); + Json(serde_json::json!({ "status": !nzo_ids.is_empty(), "nzo_ids": nzo_ids })) +} + fn handle_history(state: &AppState, req: &SabApiRequest) -> Json { let qm = &state.queue_manager; @@ -2148,4 +2211,91 @@ mod tests { sab_contract::golden(&contents); } } + + fn add_live_job(test_state: &TestState, id: &str) { + let job = NzbJob { + id: id.into(), + name: "Queue Subcommand Fixture".into(), + category: "tv".into(), + status: JobStatus::Queued, + priority: Priority::Normal, + total_bytes: 1_048_576, + downloaded_bytes: 0, + file_count: 1, + files_completed: 0, + article_count: 1, + articles_downloaded: 0, + articles_failed: 0, + added_at: chrono::Utc::now(), + completed_at: None, + work_dir: test_state.state.config().general.incomplete_dir.join(id), + output_dir: test_state.state.config().general.complete_dir.join(id), + password: None, + error_message: None, + speed_bps: 0, + server_stats: Vec::new(), + files: Vec::new(), + }; + test_state + .state + .queue_manager + .add_job(job, None) + .expect("add live queue fixture"); + } + + /// SABnzbd's real priority endpoint is `mode=queue&name=priority`, not + /// the top-level `mode=priority` this compat layer also accepts. + #[tokio::test] + async fn queue_priority_subcommand_changes_job_priority() { + let test_state = test_state(); + add_live_job(&test_state, "queue-priority-job"); + + let req = SabApiRequest { + mode: Some("queue".into()), + name: Some("priority".into()), + value: Some("queue-priority-job".into()), + value2: Some("1".into()), + ..SabApiRequest::default() + }; + let response = dispatch_mode(&test_state.state, "queue", &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + + let job = test_state + .state + .queue_manager + .get_jobs() + .into_iter() + .find(|job| job.id == "queue-priority-job") + .expect("job still queued"); + // This test covers routing (does mode=queue&name=priority reach the + // queue manager at all?), not the value mapping itself -- that's + // covered separately by sab_priority_to_priority's own tests. + assert_eq!(job.priority, sab_priority_to_priority("1")); + } + + /// SABnzbd's real rename endpoint is `mode=queue&name=rename`. + #[tokio::test] + async fn queue_rename_subcommand_renames_job() { + let test_state = test_state(); + add_live_job(&test_state, "queue-rename-job"); + + let req = SabApiRequest { + mode: Some("queue".into()), + name: Some("rename".into()), + value: Some("queue-rename-job".into()), + value2: Some("New Name".into()), + ..SabApiRequest::default() + }; + let response = dispatch_mode(&test_state.state, "queue", &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + + let job = test_state + .state + .queue_manager + .get_jobs() + .into_iter() + .find(|job| job.id == "queue-rename-job") + .expect("job still queued"); + assert_eq!(job.name, "New Name"); + } } From 50da87422b0e88a858a0dae6ad81f10c6a74d646 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:24:57 +0000 Subject: [PATCH 4/8] fix: get_cats reports "*" default-category sentinel (#73) Real SABnzbd's get_cats calls list_cats(default=False), which leaves the default category's config-internal name "*" untouched -- the "*" -> "Default" substitution only happens for the config UI (default=True). RustNZB returned the display string "Default" instead, which a client that specifically recognizes "*" as the default-category sentinel wouldn't find. Adds sab_resolve_category() to translate "*" back to RustNZB's internal "Default" category name wherever a client-supplied cat value is applied (addfile, addurl, change_cat), so both directions of the boundary translation stay consistent. --- crates/nzb-web/src/sabnzbd_compat.rs | 110 +++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 6 deletions(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..b930514 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -198,7 +198,7 @@ pub async fn h_sabnzbd_api_post( if let Some(ref c) = cat && !c.is_empty() { - job.category = c.clone(); + job.category = sab_resolve_category(c).to_string(); } if let Some(ref p) = priority { job.priority = sab_priority_to_priority(p); @@ -294,7 +294,7 @@ pub async fn h_sabnzbd_api_post( if let Some(ref c) = cat && !c.is_empty() { - job.category = c.clone(); + job.category = sab_resolve_category(c).to_string(); } if let Some(ref p) = priority { job.priority = sab_priority_to_priority(p); @@ -1172,15 +1172,42 @@ fn handle_priority(state: &AppState, req: &SabApiRequest) -> Json Json { let config = state.config(); - let mut cats: Vec = config.categories.iter().map(|c| c.name.clone()).collect(); - if !cats.iter().any(|c| c == "Default") { - cats.insert(0, "Default".into()); + let mut cats: Vec = config + .categories + .iter() + .map(|c| { + if c.name.eq_ignore_ascii_case("Default") { + SAB_DEFAULT_CATEGORY_SENTINEL.to_string() + } else { + c.name.clone() + } + }) + .collect(); + if !cats.iter().any(|c| c == SAB_DEFAULT_CATEGORY_SENTINEL) { + cats.insert(0, SAB_DEFAULT_CATEGORY_SENTINEL.into()); } Json(serde_json::json!({ "categories": cats })) } +/// Translate a client-supplied category into RustNZB's internal name, +/// resolving SABnzbd's `"*"` default-category sentinel. +fn sab_resolve_category(cat: &str) -> &str { + if cat == SAB_DEFAULT_CATEGORY_SENTINEL { + "Default" + } else { + cat + } +} + fn handle_change_cat(state: &AppState, req: &SabApiRequest) -> Json { let job_id = req.value.as_deref().unwrap_or(""); let new_cat = req.value2.as_deref().unwrap_or(""); @@ -1195,7 +1222,7 @@ fn handle_change_cat(state: &AppState, req: &SabApiRequest) -> Json Json(serde_json::json!({ "status": true })), Err(e) => Json(serde_json::json!({ "status": false, @@ -2148,4 +2175,75 @@ mod tests { sab_contract::golden(&contents); } } + + fn add_live_job(test_state: &TestState, id: &str) { + let job = NzbJob { + id: id.into(), + name: "SAB Category Sentinel Fixture".into(), + category: "Default".into(), + status: JobStatus::Queued, + priority: Priority::Normal, + total_bytes: 1_048_576, + downloaded_bytes: 0, + file_count: 1, + files_completed: 0, + article_count: 1, + articles_downloaded: 0, + articles_failed: 0, + added_at: chrono::Utc::now(), + completed_at: None, + work_dir: test_state.state.config().general.incomplete_dir.join(id), + output_dir: test_state.state.config().general.complete_dir.join(id), + password: None, + error_message: None, + speed_bps: 0, + server_stats: Vec::new(), + files: Vec::new(), + }; + test_state + .state + .queue_manager + .add_job(job, None) + .expect("add live queue fixture"); + } + + /// SABnzbd's real `get_cats` reports the default category as `"*"`, not + /// a display name -- verified against `sabnzbd/api.py::list_cats(default=False)`. + #[tokio::test] + async fn get_cats_reports_default_category_as_sabnzbd_sentinel() { + let test_state = test_state(); + let response = handle_get_cats(&test_state.state).0; + let cats = response["categories"] + .as_array() + .expect("categories array"); + assert_eq!(cats, &vec![serde_json::json!("*")]); + } + + #[tokio::test] + async fn change_cat_accepts_sabnzbd_default_sentinel() { + let test_state = test_state(); + add_live_job(&test_state, "sentinel-cat-job"); + test_state + .state + .queue_manager + .change_job_category("sentinel-cat-job", "movies") + .expect("seed non-default category"); + + let req = SabApiRequest { + value: Some("sentinel-cat-job".into()), + value2: Some("*".into()), + ..SabApiRequest::default() + }; + let response = handle_change_cat(&test_state.state, &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + + let job = test_state + .state + .queue_manager + .get_jobs() + .into_iter() + .find(|job| job.id == "sentinel-cat-job") + .expect("job still queued"); + assert_eq!(job.category, "Default"); + } } From 58209ad2bf67a0d814b9c74fd49b7d3a171e33a0 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:26:30 +0000 Subject: [PATCH 5/8] fix: implement mode=get_scripts (#74) get_scripts is a real top-level SABnzbd API mode (sabnzbd/api.py::_api_table["get_scripts"]) that fell through dispatch_mode's default arm as "Unknown mode". RustNZB doesn't support post-processing scripts, so ["None"] -- the same value real SABnzbd reports with no scripts configured -- is the correct permanent response. Clients that fetch categories and scripts together to populate an add-download dialog may abort populating the whole dialog (category picker included) if either call errors, so this is a plausible second contributor to #65 alongside #73. --- crates/nzb-web/src/sabnzbd_compat.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..cdb661e 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -378,6 +378,12 @@ fn dispatch_mode(state: &AppState, mode: &str, req: &SabApiRequest) -> Json handle_get_cats(state), + // RustNZB doesn't support post-processing scripts, so this is the + // permanent, correct response -- it matches what real SABnzbd + // reports when no script directory / scripts are configured + // (sabnzbd/api.py::_api_get_scripts -> filesystem.py::list_scripts). + "get_scripts" => Json(serde_json::json!({ "scripts": ["None"] })), + "change_cat" => handle_change_cat(state, req), "rename" => handle_rename(state, req), @@ -2148,4 +2154,17 @@ mod tests { sab_contract::golden(&contents); } } + + /// Real SABnzbd's `mode=get_scripts` always answers with at least + /// `["None"]` (sabnzbd/api.py::_api_get_scripts, + /// filesystem.py::list_scripts) -- clients that fetch categories and + /// scripts together to populate an "add download" dialog may fail to + /// populate the whole dialog if this call errors, as it previously did. + #[tokio::test] + async fn get_scripts_reports_none_when_unsupported() { + let test_state = test_state(); + let req = SabApiRequest::default(); + let response = dispatch_mode(&test_state.state, "get_scripts", &req).0; + assert_eq!(response["scripts"], serde_json::json!(["None"])); + } } From 6fdb4048e73e6d69e5c2f58eaa7064392c0768f2 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:31:58 +0000 Subject: [PATCH 6/8] fix: queue/history delete support multi-ID value and del_files (#75) Real SABnzbd's _api_queue_delete and _api_history_delete both accept a comma-separated list of nzo_ids in `value`, and _api_history_delete additionally accepts a `del_files` flag that removes the completed output directory from disk. RustNZB's handlers only matched a single ID (or the literal "all") and never freed disk space regardless of del_files. Also fixes a related gap: the POST handler's catch-all mode dispatch hardcoded `value`/`value2` to None instead of forwarding them from the query string, which would have silently broken these (and #72's priority/rename) sub-commands over POST. --- crates/nzb-web/src/sabnzbd_compat.rs | 193 ++++++++++++++++++++++++--- 1 file changed, 174 insertions(+), 19 deletions(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..1a9d3ee 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -43,6 +43,7 @@ pub struct SabApiRequest { pub archive: Option, pub last_history_update: Option, pub password: Option, + pub del_files: Option, } /// Validate API key. Returns Err with JSON response on failure. @@ -337,8 +338,12 @@ pub async fn h_sabnzbd_api_post( let req = SabApiRequest { mode: Some(mode), name, - value: None, - value2: None, + // Sub-commands like queue/history delete, priority, and + // rename take `value`/`value2` as plain query-string + // parameters even on POST -- these were previously dropped + // here, silently breaking those actions over POST. + value: query_req.value, + value2: query_req.value2, apikey, output: None, cat, @@ -353,6 +358,7 @@ pub async fn h_sabnzbd_api_post( archive: query_req.archive, last_history_update: query_req.last_history_update, password, + del_files: query_req.del_files, }; Ok(dispatch_mode( &state, @@ -671,7 +677,11 @@ fn queue_totals_include(job: &NzbJob) -> bool { !matches!(job.status, JobStatus::Completed | JobStatus::Failed) } -/// Handle mode=queue&name=delete&value=nzo_ID (SABnzbd queue delete) +/// Handle mode=queue&name=delete&value=nzo_id(s) (SABnzbd queue delete). +/// `value` may be a comma-separated list of nzo_ids, matching SABnzbd's +/// `_api_queue_delete`. RustNZB's `remove_job` already always cleans up a +/// job's incomplete work directory, so `del_files` (unlike in history +/// delete) doesn't change queue-delete behavior here. fn handle_queue_delete(state: &AppState, req: &SabApiRequest) -> Json { let target = req.value.as_deref().unwrap_or(""); if target.is_empty() { @@ -681,7 +691,7 @@ fn handle_queue_delete(state: &AppState, req: &SabApiRequest) -> Json Json = Vec::new(); + for raw_id in target.split(',').map(str::trim).filter(|id| !id.is_empty()) { + let search_id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); + if let Some(job) = jobs + .iter() + .find(|job| job.id == search_id || job.id.starts_with(search_id)) + { let _ = qm.remove_job(&job.id); tracing::info!(id = %job.id, "Job removed from queue via arr API (mode=queue)"); - return Json(serde_json::json!({ "status": true })); + removed_ids.push(queue_nzo_id(job)); } } - tracing::warn!(search = %search_id, "Queue delete: job not found"); - Json(serde_json::json!({ "status": false })) + Json(serde_json::json!({ "status": !removed_ids.is_empty(), "nzo_ids": removed_ids })) } /// Handle mode=queue&name=pause&value=nzo_ID. @@ -901,6 +913,10 @@ fn sab_query_bool(value: &str) -> bool { } /// Handle mode=history&name=delete&value=nzo_ID (SABnzbd history delete) +/// `value` may be a comma-separated list of nzo_ids, matching SABnzbd's +/// `_api_history_delete`. `del_files=1` additionally removes the entry's +/// completed output directory from disk, matching real SABnzbd -- RustNZB +/// otherwise never frees that space on history delete. fn handle_history_delete(state: &AppState, req: &SabApiRequest) -> Json { let target = req.value.as_deref().unwrap_or(""); if target.is_empty() { @@ -908,8 +924,14 @@ fn handle_history_delete(state: &AppState, req: &SabApiRequest) -> Json Json(serde_json::json!({ "status": true })), Err(error) => Json(serde_json::json!({ @@ -919,18 +941,24 @@ fn handle_history_delete(state: &AppState, req: &SabApiRequest) -> Json = Vec::new(); + for raw_id in target.split(',').map(str::trim).filter(|id| !id.is_empty()) { + let search_id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); + if let Some(entry) = entries + .iter() + .find(|entry| entry.id == search_id || entry.id.starts_with(search_id)) + { + if del_files { + let _ = std::fs::remove_dir_all(&entry.output_dir); + } let _ = qm.history_remove(&entry.id); tracing::info!(id = %entry.id, "Entry removed from history via arr API (mode=history)"); - return Json(serde_json::json!({ "status": true })); + removed_ids.push(entry.id.clone()); } } - tracing::warn!(search = %search_id, "History delete: entry not found"); - Json(serde_json::json!({ "status": false })) + Json(serde_json::json!({ "status": !removed_ids.is_empty() })) } fn handle_get_config(state: &AppState) -> Json { @@ -2148,4 +2176,131 @@ mod tests { sab_contract::golden(&contents); } } + + fn add_live_job(test_state: &TestState, id: &str) { + let job = NzbJob { + id: id.into(), + name: "Delete Fixture".into(), + category: "tv".into(), + status: JobStatus::Queued, + priority: Priority::Normal, + total_bytes: 1_048_576, + downloaded_bytes: 0, + file_count: 1, + files_completed: 0, + article_count: 1, + articles_downloaded: 0, + articles_failed: 0, + added_at: chrono::Utc::now(), + completed_at: None, + work_dir: test_state.state.config().general.incomplete_dir.join(id), + output_dir: test_state.state.config().general.complete_dir.join(id), + password: None, + error_message: None, + speed_bps: 0, + server_stats: Vec::new(), + files: Vec::new(), + }; + test_state + .state + .queue_manager + .add_job(job, None) + .expect("add live queue fixture"); + } + + /// SABnzbd's real `_api_queue_delete` accepts a comma-separated `value` + /// list, removing every matching job in one call. + #[tokio::test] + async fn queue_delete_removes_multiple_comma_separated_ids() { + let test_state = test_state(); + add_live_job(&test_state, "multi-delete-one"); + add_live_job(&test_state, "multi-delete-two"); + + let req = SabApiRequest { + value: Some("multi-delete-one,multi-delete-two".into()), + ..SabApiRequest::default() + }; + let response = handle_queue_delete(&test_state.state, &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + + let remaining = test_state.state.queue_manager.get_jobs(); + assert!( + remaining + .iter() + .all(|job| job.id != "multi-delete-one" && job.id != "multi-delete-two") + ); + } + + fn insert_history_fixture(test_state: &TestState, id: &str, output_dir: std::path::PathBuf) { + let entry = HistoryEntry { + id: id.into(), + name: id.into(), + category: "tv".into(), + status: JobStatus::Completed, + total_bytes: 10_000, + downloaded_bytes: 10_000, + added_at: chrono::Utc::now() - chrono::Duration::seconds(20), + completed_at: chrono::Utc::now(), + download_time_secs: Some(1.0), + output_dir, + stages: Vec::new(), + error_message: None, + server_stats: Vec::new(), + nzb_data: None, + }; + test_state.state.queue_manager.with_db(|database| { + database + .history_insert(&entry) + .expect("insert history fixture") + }); + } + + /// Real SABnzbd's `_api_history_delete` removes the completed output + /// directory from disk when `del_files=1` is set; RustNZB previously + /// never freed that space regardless of the flag. + #[tokio::test] + async fn history_delete_with_del_files_removes_output_directory() { + let test_state = test_state(); + let output_dir = test_state + .state + .config() + .general + .complete_dir + .join("del-files-job"); + std::fs::create_dir_all(&output_dir).expect("create fixture output dir"); + std::fs::write(output_dir.join("file.mkv"), b"data").expect("write fixture file"); + insert_history_fixture(&test_state, "del-files-job", output_dir.clone()); + + let req = SabApiRequest { + value: Some("del-files-job".into()), + del_files: Some("1".into()), + ..SabApiRequest::default() + }; + let response = handle_history_delete(&test_state.state, &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + assert!(!output_dir.exists()); + } + + /// Without `del_files`, history delete only removes the DB record, as + /// before. + #[tokio::test] + async fn history_delete_without_del_files_keeps_output_directory() { + let test_state = test_state(); + let output_dir = test_state + .state + .config() + .general + .complete_dir + .join("keep-files-job"); + std::fs::create_dir_all(&output_dir).expect("create fixture output dir"); + insert_history_fixture(&test_state, "keep-files-job", output_dir.clone()); + + let req = SabApiRequest { + value: Some("keep-files-job".into()), + ..SabApiRequest::default() + }; + let response = handle_history_delete(&test_state.state, &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + assert!(output_dir.exists()); + } } From b3199737e0fd09b39173e44bf62ba27b85a15c47 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:33:58 +0000 Subject: [PATCH 7/8] fix: change_cat accepts multiple comma-separated nzo_ids (#76) Real SABnzbd's _api_change_cat parses `value` as a comma-separated list of nzo_ids via clean_comma_separated_list, applying the category change to all of them. handle_change_cat treated `value` as a single job ID, so a multi-ID request (e.g. bulk re-categorize) matched no job and silently failed for all of them. --- crates/nzb-web/src/sabnzbd_compat.rs | 79 ++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index 399e1dc..97d2676 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -1181,27 +1181,29 @@ fn handle_get_cats(state: &AppState) -> Json { Json(serde_json::json!({ "categories": cats })) } +/// `value` may be a comma-separated list of nzo_ids, matching SABnzbd's +/// `_api_change_cat` (`nzo_ids = clean_comma_separated_list(kwargs.get("value"))`). fn handle_change_cat(state: &AppState, req: &SabApiRequest) -> Json { - let job_id = req.value.as_deref().unwrap_or(""); + let job_ids = req.value.as_deref().unwrap_or(""); let new_cat = req.value2.as_deref().unwrap_or(""); - if job_id.is_empty() || new_cat.is_empty() { + if job_ids.is_empty() || new_cat.is_empty() { return Json(serde_json::json!({ "status": false, "error": "Missing value (job id) or value2 (category)" })); } - let search_id = job_id.strip_prefix("SABnzbd_nzo_").unwrap_or(job_id); - let qm = &state.queue_manager; - match qm.change_job_category(search_id, new_cat) { - Ok(()) => Json(serde_json::json!({ "status": true })), - Err(e) => Json(serde_json::json!({ - "status": false, - "error": format!("{e}") - })), + let mut changed = false; + for raw_id in job_ids.split(',').map(str::trim).filter(|id| !id.is_empty()) { + let search_id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); + if qm.change_job_category(search_id, new_cat).is_ok() { + changed = true; + } } + + Json(serde_json::json!({ "status": changed })) } fn handle_rename(state: &AppState, req: &SabApiRequest) -> Json { @@ -2148,4 +2150,61 @@ mod tests { sab_contract::golden(&contents); } } + + fn add_live_job(test_state: &TestState, id: &str, category: &str) { + let job = NzbJob { + id: id.into(), + name: "Change Cat Fixture".into(), + category: category.into(), + status: JobStatus::Queued, + priority: Priority::Normal, + total_bytes: 1_048_576, + downloaded_bytes: 0, + file_count: 1, + files_completed: 0, + article_count: 1, + articles_downloaded: 0, + articles_failed: 0, + added_at: chrono::Utc::now(), + completed_at: None, + work_dir: test_state.state.config().general.incomplete_dir.join(id), + output_dir: test_state.state.config().general.complete_dir.join(id), + password: None, + error_message: None, + speed_bps: 0, + server_stats: Vec::new(), + files: Vec::new(), + }; + test_state + .state + .queue_manager + .add_job(job, None) + .expect("add live queue fixture"); + } + + /// SABnzbd's real `_api_change_cat` accepts a comma-separated `value` + /// list, applying the category change to every matching job. + #[tokio::test] + async fn change_cat_applies_to_multiple_comma_separated_ids() { + let test_state = test_state(); + add_live_job(&test_state, "multi-cat-one", "tv"); + add_live_job(&test_state, "multi-cat-two", "tv"); + + let req = SabApiRequest { + value: Some("multi-cat-one,multi-cat-two".into()), + value2: Some("movies".into()), + ..SabApiRequest::default() + }; + let response = handle_change_cat(&test_state.state, &req).0; + assert_eq!(response["status"], serde_json::json!(true)); + + let jobs = test_state.state.queue_manager.get_jobs(); + for id in ["multi-cat-one", "multi-cat-two"] { + let job = jobs + .iter() + .find(|job| job.id == id) + .unwrap_or_else(|| panic!("job {id} still queued")); + assert_eq!(job.category, "movies"); + } + } } From f02f9ede598ca63d75a92127191c8695bae53891 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:45:01 +0000 Subject: [PATCH 8/8] fix: resolve full job id before set_job_priority; add e2e SAB compliance suite (#77) set_job_priority requires an exact job-id match, but clients only ever send the truncated SABnzbd_nzo_<12 chars> form -- both the top-level handle_priority and the new mode=queue&name=priority route stripped the prefix and passed the truncated id straight through, so priority changes always silently failed against a real nzo_id. Resolve the full job id by prefix first, the same way pause/resume/rename/ change_cat already do. This was a pre-existing bug caught by the new end-to-end suite below, not introduced by the queue-routing fix. Adds apps/rustnzb/tests/sab_compliance_e2e.rs: a durable HTTP-level test suite (spins up the real router via axum::serve + reqwest, unlike the handler-function-level tests in sabnzbd_compat.rs) that exercises version, addfile, addurl (GET), get_cats, get_scripts, mode=queue&name=priority/rename/delete, and change_cat -- the exact surface area that drifted from the real SABnzbd protocol across issues #65 and #71-#76 without any test catching it. --- apps/rustnzb/tests/sab_compliance_e2e.rs | 383 +++++++++++++++++++++++ crates/nzb-web/src/sabnzbd_compat.rs | 27 +- 2 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 apps/rustnzb/tests/sab_compliance_e2e.rs diff --git a/apps/rustnzb/tests/sab_compliance_e2e.rs b/apps/rustnzb/tests/sab_compliance_e2e.rs new file mode 100644 index 0000000..2a6df74 --- /dev/null +++ b/apps/rustnzb/tests/sab_compliance_e2e.rs @@ -0,0 +1,383 @@ +//! Durable end-to-end coverage for RustNZB's SABnzbd-compatible API +//! (`/sabnzbd/api`), exercised over real HTTP against a live server instance +//! -- not just the in-process handler-function tests in +//! `nzb-web/src/sabnzbd_compat.rs`. +//! +//! This suite exists because rustnzb's SAB compatibility layer drifted from +//! the real protocol several times without any test catching it (see +//! TheDancingDeveloper-org/rustnzb#65, #71-#76): a real endpoint +//! (`mode=queue&name=priority`) was entirely unreachable, numeric priority +//! codes were shifted by one, `get_cats`/`get_scripts` didn't match what a +//! compliant client actually receives, and `addurl` didn't work over GET at +//! all. Each assertion below is paired with the exact upstream SABnzbd +//! (`sabnzbd/sabnzbd@5.1.x`) source it was verified against, so a future +//! change that reintroduces one of these regressions fails immediately +//! instead of silently shipping. + +mod support; + +use support::{sample_nzb_bytes, start_test_server}; + +/// Serves `body` once over a raw TCP listener, returning the URL to fetch it +/// from. Used to exercise `mode=addurl` (a real HTTP GET fetch) without +/// depending on an external network. +async fn spawn_nzb_server(body: Vec) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral test server"); + let addr = listener.local_addr().expect("test server local addr"); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept test connection"); + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let mut response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-nzb\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(&body); + let _ = socket.write_all(&response).await; + let _ = socket.shutdown().await; + }); + + format!("http://{addr}/test.nzb") +} + +/// `mode=version` -- baseline connectivity check every SAB-compatible client +/// performs first. +#[tokio::test] +async fn version_reports_a_string() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + + let response: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=version", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert!(response["version"].as_str().is_some()); +} + +/// `mode=addfile` (POST multipart) must apply `cat`/`priority`, and the +/// resulting queue slot must report SABnzbd's real priority vocabulary +/// ("High", not "Normal") -- regression coverage for the numeric mapping fix +/// (rustnzb#71) verified against `sabnzbd/constants.py: HIGH_PRIORITY = 1`. +#[tokio::test] +async fn addfile_applies_category_and_priority_and_reports_them_in_queue() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + + let form = reqwest::multipart::Form::new() + .text("mode", "addfile") + .text("cat", "tv") + .text("priority", "1") + .part( + "name", + reqwest::multipart::Part::bytes(sample_nzb_bytes()) + .file_name("sample.nzb") + .mime_str("application/x-nzb") + .unwrap(), + ); + + let add_response: serde_json::Value = client + .post(format!("{}/sabnzbd/api", app.base_url)) + .multipart(form) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(add_response["status"], serde_json::json!(true), "resp={add_response:?}"); + let nzo_id = add_response["nzo_ids"][0].as_str().unwrap().to_string(); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slot = queue["queue"]["slots"] + .as_array() + .unwrap() + .iter() + .find(|slot| slot["nzo_id"] == nzo_id) + .expect("added job present in queue"); + assert_eq!(slot["cat"], "tv"); + // sabnzbd/constants.py: HIGH_PRIORITY = 1 -> "High", not "Normal". + assert_eq!(slot["priority"], "High"); +} + +/// `mode=addurl` fetches a remote NZB and has no file body to upload, so +/// real SABnzbd (and clients like NZB360/Sonarr/Radarr) issue it as a plain +/// GET. Regression coverage for rustnzb#65/PR#70: GET requests used to fall +/// through to "Unknown mode" and silently drop `cat`. +#[tokio::test] +async fn addurl_over_get_fetches_and_applies_category() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let nzb_url = spawn_nzb_server(sample_nzb_bytes()).await; + + let add_response: serde_json::Value = client + .get(format!( + "{}/sabnzbd/api?mode=addurl&name={}&cat=movies", + app.base_url, nzb_url + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(add_response["status"], serde_json::json!(true), "resp={add_response:?}"); + let nzo_id = add_response["nzo_ids"][0].as_str().unwrap().to_string(); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slot = queue["queue"]["slots"] + .as_array() + .unwrap() + .iter() + .find(|slot| slot["nzo_id"] == nzo_id) + .expect("URL-added job present in queue"); + assert_eq!(slot["cat"], "movies"); +} + +/// `mode=get_cats` must report the default category as the literal `"*"` +/// sentinel, matching `sabnzbd/api.py::list_cats(default=False)` -- not a +/// display name like "Default". Regression coverage for rustnzb#73. +#[tokio::test] +async fn get_cats_reports_sabnzbd_default_sentinel() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + + let response: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=get_cats", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_eq!(response["categories"], serde_json::json!(["*"])); +} + +/// `mode=get_scripts` is a real top-level SABnzbd mode +/// (`sabnzbd/api.py::_api_table["get_scripts"]`) that used to fall through +/// to "Unknown mode". Regression coverage for rustnzb#74. +#[tokio::test] +async fn get_scripts_reports_none() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + + let response: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=get_scripts", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_eq!(response["scripts"], serde_json::json!(["None"])); +} + +async fn add_queued_job(app: &support::TestApp, client: &reqwest::Client) -> String { + let form = reqwest::multipart::Form::new() + .text("mode", "addfile") + .part( + "name", + reqwest::multipart::Part::bytes(sample_nzb_bytes()) + .file_name("sample.nzb") + .mime_str("application/x-nzb") + .unwrap(), + ); + let add_response: serde_json::Value = client + .post(format!("{}/sabnzbd/api", app.base_url)) + .multipart(form) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(add_response["status"], serde_json::json!(true)); + add_response["nzo_ids"][0].as_str().unwrap().to_string() +} + +/// Real SABnzbd has no top-level `mode=priority`; priority changes are a +/// sub-command of `mode=queue` (`sabnzbd/api.py::_api_queue_table["priority"]`). +/// Regression coverage for rustnzb#72: this route used to silently fall +/// through to a plain queue listing instead of applying the change. +#[tokio::test] +async fn queue_priority_subcommand_changes_priority_over_http() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let nzo_id = add_queued_job(&app, &client).await; + + let response: serde_json::Value = client + .get(format!( + "{}/sabnzbd/api?mode=queue&name=priority&value={nzo_id}&value2=2", + app.base_url + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["status"], serde_json::json!(true)); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slot = queue["queue"]["slots"] + .as_array() + .unwrap() + .iter() + .find(|slot| slot["nzo_id"] == nzo_id) + .expect("job present in queue"); + // sabnzbd/constants.py: FORCE_PRIORITY = 2 -> "Force". + assert_eq!(slot["priority"], "Force"); +} + +/// Real SABnzbd's rename is also a `mode=queue` sub-command +/// (`_api_queue_table["rename"]`). Regression coverage for rustnzb#72. +#[tokio::test] +async fn queue_rename_subcommand_renames_over_http() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let nzo_id = add_queued_job(&app, &client).await; + + let response: serde_json::Value = client + .get(format!( + "{}/sabnzbd/api?mode=queue&name=rename&value={nzo_id}&value2=Renamed%20Job", + app.base_url + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["status"], serde_json::json!(true)); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slot = queue["queue"]["slots"] + .as_array() + .unwrap() + .iter() + .find(|slot| slot["nzo_id"] == nzo_id) + .expect("job present in queue"); + assert_eq!(slot["filename"], "Renamed Job"); +} + +/// `mode=change_cat` must accept a comma-separated `value` list, applying +/// the category to every matching job in one call +/// (`sabnzbd/api.py::_api_change_cat` -> `clean_comma_separated_list`). +/// Regression coverage for rustnzb#76. +#[tokio::test] +async fn change_cat_applies_to_multiple_jobs_over_http() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let first = add_queued_job(&app, &client).await; + let second = add_queued_job(&app, &client).await; + + let response: serde_json::Value = client + .get(format!( + "{}/sabnzbd/api?mode=change_cat&value={first},{second}&value2=movies", + app.base_url + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["status"], serde_json::json!(true)); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slots = queue["queue"]["slots"].as_array().unwrap(); + for nzo_id in [&first, &second] { + let slot = slots + .iter() + .find(|slot| slot["nzo_id"] == *nzo_id) + .expect("job present in queue"); + assert_eq!(slot["cat"], "movies"); + } +} + +/// `mode=queue&name=delete` must accept a comma-separated `value` list, +/// removing every matching job in one call +/// (`sabnzbd/api.py::_api_queue_delete` -> `clean_comma_separated_list`). +/// Regression coverage for rustnzb#75. +#[tokio::test] +async fn queue_delete_removes_multiple_jobs_over_http() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let first = add_queued_job(&app, &client).await; + let second = add_queued_job(&app, &client).await; + + let response: serde_json::Value = client + .get(format!( + "{}/sabnzbd/api?mode=queue&name=delete&value={first},{second}", + app.base_url + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["status"], serde_json::json!(true)); + + let queue: serde_json::Value = client + .get(format!("{}/sabnzbd/api?mode=queue", app.base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slots = queue["queue"]["slots"].as_array().unwrap(); + assert!( + slots + .iter() + .all(|slot| slot["nzo_id"] != first && slot["nzo_id"] != second) + ); +} diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index ef2ac2a..1dda154 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -790,10 +790,18 @@ fn handle_queue_priority(state: &AppState, req: &SabApiRequest) -> Json form -- resolve the full id + // by prefix first, the same way pause/resume/rename/change_cat do. + let jobs = qm.get_jobs(); let mut applied = false; for raw_id in target.split(',').map(str::trim).filter(|id| !id.is_empty()) { - let id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); - if qm.set_job_priority(id, priority_value).is_ok() { + let search_id = raw_id.strip_prefix("SABnzbd_nzo_").unwrap_or(raw_id); + if let Some(job) = jobs + .iter() + .find(|job| job.id == search_id || job.id.starts_with(search_id)) + && qm.set_job_priority(&job.id, priority_value).is_ok() + { applied = true; } } @@ -1259,11 +1267,16 @@ fn handle_priority(state: &AppState, req: &SabApiRequest) -> Json Json(serde_json::json!({ "status": true })), Err(error) => Json(serde_json::json!({ "status": false, "error": error.to_string() })), }