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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user