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 97dd982..d4bddc5 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. @@ -160,7 +161,7 @@ async fn handle_addurl( 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); @@ -320,7 +321,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); @@ -368,8 +369,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, @@ -384,6 +389,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, @@ -409,6 +415,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), @@ -524,11 +536,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 })), _ => {} } @@ -702,7 +721,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() { @@ -712,7 +735,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. @@ -781,6 +806,70 @@ 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; + // set_job_priority requires an exact job-id match, but clients only ever + // know the truncated SABnzbd_nzo_<12 chars> 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 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; + } + } + + 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; @@ -932,6 +1021,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() { @@ -939,8 +1032,14 @@ fn handle_history_delete(state: &AppState, req: &SabApiRequest) -> Json Json(serde_json::json!({ "status": true })), Err(error) => Json(serde_json::json!({ @@ -950,18 +1049,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 { @@ -1193,46 +1298,81 @@ 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() })), } } +/// SABnzbd's `get_cats` reports the default category as the literal +/// sentinel `"*"`, not a display name -- verified against +/// `sabnzbd/sabnzbd@5.1.x`, `sabnzbd/api.py::list_cats(default=False)`. +/// RustNZB's own category model still names that category "Default" +/// internally, so translate at the API boundary in both directions. +const SAB_DEFAULT_CATEGORY_SENTINEL: &str = "*"; + fn handle_get_cats(state: &AppState) -> 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 + } +} + +/// `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 resolved_cat = sab_resolve_category(new_cat); + 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, resolved_cat).is_ok() { + changed = true; + } } + + Json(serde_json::json!({ "status": changed })) } fn handle_rename(state: &AppState, req: &SabApiRequest) -> Json { @@ -1261,10 +1401,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, } } @@ -2180,6 +2323,328 @@ mod tests { } } + /// 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)); + } + } + + fn add_live_job(test_state: &TestState, id: &str) { + let job = NzbJob { + id: id.into(), + name: "Compat Layer 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"); + } + + fn add_live_job_with_category(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 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"); + } + + /// 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"); + } + + /// 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_with_category(&test_state, "multi-cat-one", "tv"); + add_live_job_with_category(&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"); + } + } + + /// 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"])); + } + + /// 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()); + } + const SAMPLE_NZB: &str = r#"