From 50da87422b0e88a858a0dae6ad81f10c6a74d646 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Tue, 11 Aug 2026 09:24:57 +0000 Subject: [PATCH] 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"); + } }