Treat manual storage as Importarr-owned

Use SAB storage under the configured download root as ownership even when SAB reports category '*'.
This commit is contained in:
2026-07-29 15:12:04 +02:00
parent 22a1fc5522
commit ce82a405c5
4 changed files with 32 additions and 14 deletions
+3 -3
View File
@@ -317,9 +317,9 @@ async def sync_queue() -> None:
continue continue
if readiness.ready and readiness.storage: if readiness.ready and readiness.storage:
for video in scan_videos(readiness.storage): for video in scan_videos(readiness.storage):
state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id) state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
else: else:
state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=readiness.state, reason=readiness.reason, job_id=job_id) state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=readiness.state, reason=readiness.reason, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
def queue_jobs() -> list[dict[str, object]]: def queue_jobs() -> list[dict[str, object]]:
@@ -346,7 +346,7 @@ def serialize_queue_item(item: dict[str, object]) -> dict[str, object]:
"updated_at": item["updated_at"], "updated_at": item["updated_at"],
"completed_at": item["completed_at"], "completed_at": item["completed_at"],
"sab_status": state_name if source_type == "sab" else None, "sab_status": state_name if source_type == "sab" else None,
"sab_category": settings.sab_category if source_type == "sab" else None, "sab_category": item.get("sab_category") if source_type == "sab" else None,
"can_run_now": state_name in {"ready", "manual_batch", "failed"}, "can_run_now": state_name in {"ready", "manual_batch", "failed"},
"can_retry": state_name in {"failed", "skipped"}, "can_retry": state_name in {"failed", "skipped"},
"can_ignore": state_name not in {"imported", "skipped"}, "can_ignore": state_name not in {"imported", "skipped"},
+14 -8
View File
@@ -31,21 +31,27 @@ def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], catego
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")
item_category = str(item.get("category") or item.get("cat") or "") item_category = str(item.get("category") or item.get("cat") or "")
if item_category != category:
return Readiness("ignored", "SAB category is not owned by Importarr")
status = str(item.get("status") or "") status = str(item.get("status") or "")
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))
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": if not force_status and status == "Failed":
return Readiness("failed", "SAB history reports failure") return Readiness("failed", "SAB history reports failure")
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"): if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
return Readiness("processing", f"SAB status is {status or 'unknown'}") return Readiness("processing", f"SAB status is {status or 'unknown'}")
storage_value = str(item.get("storage") or "") if storage is None:
if not storage_value:
return Readiness("unknown", "SAB completed item has no final storage") return Readiness("unknown", "SAB completed item has no final storage")
storage = Path(storage_value).resolve() if not storage_in_root:
root = download_root.resolve()
if storage != root and root not in storage.parents:
return Readiness("ignored", "SAB storage is outside configured download root", storage) return Readiness("ignored", "SAB storage is outside configured download root", storage)
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)
reason = "forced despite SAB status" if force_status and status != "Completed" else "SAB completed in owned category with final storage" if force_status and status != "Completed":
reason = "forced despite SAB status"
elif item_category != category:
reason = "SAB completed inside Importarr download root"
else:
reason = "SAB completed in owned category with final storage"
return Readiness("ready", reason, storage) return Readiness("ready", reason, storage)
+9 -3
View File
@@ -46,6 +46,7 @@ class State:
size integer not null default 0, size integer not null default 0,
batch_id integer, batch_id integer,
job_id text, job_id text,
sab_category text,
first_seen_at text not null default current_timestamp, first_seen_at text not null default current_timestamp,
updated_at text not null default current_timestamp, updated_at text not null default current_timestamp,
completed_at text, completed_at text,
@@ -53,6 +54,9 @@ class State:
); );
""" """
) )
columns = {row["name"] for row in self.conn.execute("pragma table_info(import_queue_items)")}
if "sab_category" not in columns:
self.conn.execute("alter table import_queue_items add column sab_category text")
self.conn.commit() self.conn.commit()
def get_app_state(self, key: str, default: str | None = None) -> str | None: def get_app_state(self, key: str, default: str | None = None) -> str | None:
@@ -111,11 +115,12 @@ class State:
size: int = 0, size: int = 0,
batch_id: int | None = None, batch_id: int | None = None,
job_id: str | None = None, job_id: str | None = None,
sab_category: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
self.conn.execute( self.conn.execute(
""" """
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id) insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
values (?,?,?,?,?,?,?,?,?,?) values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(source_type, source_id) do update set on conflict(source_type, source_id) do update set
source_path=excluded.source_path, source_path=excluded.source_path,
name=excluded.name, name=excluded.name,
@@ -125,10 +130,11 @@ class State:
size=excluded.size, size=excluded.size,
batch_id=excluded.batch_id, batch_id=excluded.batch_id,
job_id=excluded.job_id, job_id=excluded.job_id,
sab_category=excluded.sab_category,
updated_at=current_timestamp, updated_at=current_timestamp,
completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end
""", """,
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id), (source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category),
) )
self.conn.commit() self.conn.commit()
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone() row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
+6
View File
@@ -19,6 +19,12 @@ def test_completed_manual_is_ready():
def test_wrong_category_ignored(): def test_wrong_category_ignored():
result = classify_history_item(item(category="*"), set(), "manual", ROOT) result = classify_history_item(item(category="*"), set(), "manual", ROOT)
assert result.ready
assert result.reason == "SAB completed inside Importarr download root"
def test_wrong_category_outside_root_ignored():
result = classify_history_item(item(category="*", storage="/tmp/other/Movie"), set(), "manual", ROOT)
assert result.state == "ignored" assert result.state == "ignored"