From f9eb633e19971bfeebc6edd12367c80f1d521547 Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 15:15:15 +0200 Subject: [PATCH] Map SAB storage root safely Only treat configured manual storage as Importarr-owned and map SAB container paths to local paths. --- deploy/importarr.env.example | 3 +++ importarr/config.py | 2 ++ importarr/main.py | 4 ++-- importarr/readiness.py | 9 +++++++-- tests/test_sab_readiness.py | 24 ++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/deploy/importarr.env.example b/deploy/importarr.env.example index 2f69623..b15b06f 100644 --- a/deploy/importarr.env.example +++ b/deploy/importarr.env.example @@ -3,6 +3,9 @@ IMPORTARR_SAB_URL=http://sabnzbd:8080 # IMPORTARR_SAB_API_KEY=change-me # IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key IMPORTARR_SAB_CATEGORY=manual +# SAB may report storage paths from inside its container; set this when that +# differs from the local host path Importarr scans in IMPORTARR_DOWNLOAD_ROOT. +# IMPORTARR_SAB_STORAGE_ROOT=/data/downloads/manual IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual IMPORTARR_MOVIES_ROOT=/data/movies IMPORTARR_TV_ROOT=/data/tv diff --git a/importarr/config.py b/importarr/config.py index 57bb377..dd21bb8 100644 --- a/importarr/config.py +++ b/importarr/config.py @@ -10,6 +10,7 @@ class Settings(BaseModel): sab_url: str = "http://sabnzbd:8080" sab_api_key: str | None = None sab_category: str = "manual" + sab_storage_root: Path | None = None download_root: Path = Path("/data/downloads/manual") movies_root: Path = Path("/data/movies") tv_root: Path = Path("/data/tv") @@ -30,6 +31,7 @@ class Settings(BaseModel): sab_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default), sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"), sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"), + sab_storage_root=Path(os.getenv("IMPORTARR_SAB_STORAGE_ROOT")) if os.getenv("IMPORTARR_SAB_STORAGE_ROOT") else None, download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")), movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")), tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")), diff --git a/importarr/main.py b/importarr/main.py index fef4903..c725c51 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -311,7 +311,7 @@ async def sync_queue() -> None: slots = data.get("history", {}).get("slots", []) state.delete_queue_items_by_state("sab", "ignored", "SAB category is not owned by Importarr") for item in slots: - readiness = classify_history_item(item, active, settings.sab_category, settings.download_root) + readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, sab_storage_root=settings.sab_storage_root) job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "") if not job_id: continue @@ -422,7 +422,7 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int for item in data.get("history", {}).get("slots", []): if consume_cancel_request(): break - readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force) + readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force, sab_storage_root=settings.sab_storage_root) if readiness.storage is None or (not readiness.ready and not force): continue for video in scan_videos(readiness.storage): diff --git a/importarr/readiness.py b/importarr/readiness.py index ccd865a..2263465 100644 --- a/importarr/readiness.py +++ b/importarr/readiness.py @@ -26,7 +26,7 @@ def has_transient_part(path: Path) -> bool: return any(part in TRANSIENT_PARTS or any(token in part for token in TRANSIENT_PARTS) for part in path.parts) -def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False) -> Readiness: +def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False, sab_storage_root: Path | None = None) -> Readiness: nzo_id = str(item.get("nzo_id") or item.get("nzoid") or "") if not force_status and nzo_id and nzo_id in active_nzo_ids: return Readiness("processing", "SAB job is still present in queue") @@ -35,7 +35,10 @@ def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], catego storage_value = str(item.get("storage") or "") storage = Path(storage_value).resolve() if storage_value else None root = download_root.resolve() - storage_in_root = bool(storage and (storage == root or root in storage.parents)) + sab_root = (sab_storage_root or download_root).resolve() + storage_in_local_root = bool(storage and (storage == root or root in storage.parents)) + storage_in_sab_root = bool(storage and (storage == sab_root or sab_root in storage.parents)) + storage_in_root = storage_in_local_root or storage_in_sab_root if item_category != category and not storage_in_root: return Readiness("ignored", "SAB category/storage is not owned by Importarr", storage) if not force_status and status == "Failed": @@ -46,6 +49,8 @@ def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], catego return Readiness("unknown", "SAB completed item has no final storage") if not storage_in_root: return Readiness("ignored", "SAB storage is outside configured download root", storage) + if storage_in_sab_root and not storage_in_local_root: + storage = root / storage.relative_to(sab_root) if has_transient_part(storage): return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage) if force_status and status != "Completed": diff --git a/tests/test_sab_readiness.py b/tests/test_sab_readiness.py index 619c3ce..f61739f 100644 --- a/tests/test_sab_readiness.py +++ b/tests/test_sab_readiness.py @@ -28,6 +28,30 @@ def test_wrong_category_outside_root_ignored(): assert result.state == "ignored" +def test_sab_storage_root_maps_to_local_download_root(): + result = classify_history_item( + item(category="*", storage="/data/downloads/manual/Movie"), + set(), + "manual", + ROOT, + sab_storage_root=Path("/data/downloads/manual"), + ) + assert result.ready + assert result.storage == ROOT / "Movie" + + +def test_radarr_sonarr_storage_roots_are_not_importarr_owned(): + for storage in ("/data/downloads/movies/Movie", "/data/downloads/tv/Show"): + result = classify_history_item( + item(category="*", storage=storage), + set(), + "manual", + ROOT, + sab_storage_root=Path("/data/downloads/manual"), + ) + assert result.state == "ignored" + + def test_sab_cat_field_is_treated_as_category(): result = classify_history_item(item(category=None, cat="manual"), set(), "manual", ROOT) assert result.ready