Map SAB storage root safely

Only treat configured manual storage as Importarr-owned and map SAB container paths to local paths.
This commit is contained in:
2026-07-29 15:15:15 +02:00
parent ce82a405c5
commit f9eb633e19
5 changed files with 38 additions and 4 deletions
+3
View File
@@ -3,6 +3,9 @@ IMPORTARR_SAB_URL=http://sabnzbd:8080
# IMPORTARR_SAB_API_KEY=change-me # IMPORTARR_SAB_API_KEY=change-me
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key # IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
IMPORTARR_SAB_CATEGORY=manual 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_DOWNLOAD_ROOT=/data/downloads/manual
IMPORTARR_MOVIES_ROOT=/data/movies IMPORTARR_MOVIES_ROOT=/data/movies
IMPORTARR_TV_ROOT=/data/tv IMPORTARR_TV_ROOT=/data/tv
+2
View File
@@ -10,6 +10,7 @@ class Settings(BaseModel):
sab_url: str = "http://sabnzbd:8080" sab_url: str = "http://sabnzbd:8080"
sab_api_key: str | None = None sab_api_key: str | None = None
sab_category: str = "manual" sab_category: str = "manual"
sab_storage_root: Path | None = None
download_root: Path = Path("/data/downloads/manual") download_root: Path = Path("/data/downloads/manual")
movies_root: Path = Path("/data/movies") movies_root: Path = Path("/data/movies")
tv_root: Path = Path("/data/tv") 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_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default),
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"), sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"), 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")), download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")), movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")), tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")),
+2 -2
View File
@@ -311,7 +311,7 @@ async def sync_queue() -> None:
slots = data.get("history", {}).get("slots", []) slots = data.get("history", {}).get("slots", [])
state.delete_queue_items_by_state("sab", "ignored", "SAB category is not owned by Importarr") state.delete_queue_items_by_state("sab", "ignored", "SAB category is not owned by Importarr")
for item in slots: 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 "") job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
if not job_id: if not job_id:
continue 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", []): for item in data.get("history", {}).get("slots", []):
if consume_cancel_request(): if consume_cancel_request():
break 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): if readiness.storage is None or (not readiness.ready and not force):
continue continue
for video in scan_videos(readiness.storage): for video in scan_videos(readiness.storage):
+7 -2
View File
@@ -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) 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 "") 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: if not force_status and nzo_id and nzo_id in active_nzo_ids:
return Readiness("processing", "SAB job is still present in queue") 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_value = str(item.get("storage") or "")
storage = Path(storage_value).resolve() if storage_value else None storage = Path(storage_value).resolve() if storage_value else None
root = download_root.resolve() 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: if item_category != category and not storage_in_root:
return Readiness("ignored", "SAB category/storage is not owned by Importarr", storage) return Readiness("ignored", "SAB category/storage is not owned by Importarr", storage)
if not force_status and status == "Failed": 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") return Readiness("unknown", "SAB completed item has no final storage")
if not storage_in_root: if not storage_in_root:
return Readiness("ignored", "SAB storage is outside configured download root", storage) 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): if has_transient_part(storage):
return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage) return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage)
if force_status and status != "Completed": if force_status and status != "Completed":
+24
View File
@@ -28,6 +28,30 @@ def test_wrong_category_outside_root_ignored():
assert result.state == "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(): def test_sab_cat_field_is_treated_as_category():
result = classify_history_item(item(category=None, cat="manual"), set(), "manual", ROOT) result = classify_history_item(item(category=None, cat="manual"), set(), "manual", ROOT)
assert result.ready assert result.ready