The earlier #721 fix tolerated a ~10s "completed but no save_path" window, but the real production stall sits upstream of that: SABnzbd removes a finished download from the queue and runs par2 verify / repair / unpack *in History*, exposing the live stage in the slot `status` ('Verifying' / 'Repairing' / 'Extracting' / 'Moving' / ...) with `storage` empty until the final move. `_parse_history_slot` mapped EVERY non-'Failed' status to 'completed', so a still-extracting 1.7 GB FLAC album looked "completed with no save_path" the instant download hit 100%. The poll burned its completed-no-path budget mid-PP and bailed, freezing the UI on the last download emit (the stuck-at-99%/100% signature). SAB then finished fine — which is why the job shows Completed in History but SoulSync never staged it. Root fix - `_parse_history_slot` routes `status` through `_map_state`, so PP stages stay NON-terminal: the poll keeps waiting (as 'downloading') for as long as post-processing takes and only a real 'Completed' flips to terminal success. `save_path` is trusted only on true completion (mid-PP path fields may point at the incomplete dir). Supporting / defensive - `UsenetStatus.incomplete_path`: surfaced separately from save_path (SAB `incomplete_path`) and used by the poll loops as a LAST RESORT after the completed-no-path window, to recover the case where `storage` never lands but the files are physically on disk. - `poll_album_download`: dedicated, configurable completed-no-path window (~120s via `download_source.album_bundle_completed_no_path_seconds`) decoupled from the ~10s transient-miss window; incomplete_path fallback; a 30s heartbeat log so the previously-silent poll loop is diagnosable. - `usenet.py` `_download_thread`: per-track parity — it was erroring immediately on the first completed-no-path read. - `album_bundle_dispatch.py` / `status.py` / `monitor.py`: use the project `get_logger` so download-flow logs land in app.log under the `soulsync.*` namespace (they were console-only before, which hid the `[Album Bundle] flow failed` line during triage). Tests - PP-history state mapping; end-to-end Hunky Dory PP regression (download -> Verifying/Extracting in History past both budgets -> Completed+storage -> success); completed-no-path window + incomplete_path fallback; per-track thread parity. ruff + compileall + pytest all green (the only local failures are environmental: missing tzdata + local tools/ffmpeg.exe, neither present on CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
3 KiB
Python
83 lines
3 KiB
Python
"""Usenet client adapter contract.
|
||
|
||
``UsenetClientAdapter`` mirrors ``TorrentClientAdapter`` in shape so
|
||
the download plugin layer can reuse the same dispatch pattern.
|
||
Differences from the torrent side:
|
||
|
||
- No magnet URI equivalent — usenet jobs are always ``.nzb`` files
|
||
or URLs that resolve to one.
|
||
- No seed/peer counts — usenet is a download-only protocol.
|
||
- Status values reflect usenet semantics: ``downloading`` /
|
||
``extracting`` / ``verifying`` / ``repairing`` / ``completed`` /
|
||
``failed`` / ``paused``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import List, Optional, Protocol, runtime_checkable
|
||
|
||
|
||
@dataclass
|
||
class UsenetStatus:
|
||
"""Adapter-uniform view of one usenet job.
|
||
|
||
Field semantics:
|
||
- ``state`` is one of: ``queued`` | ``downloading`` | ``extracting``
|
||
| ``verifying`` | ``repairing`` | ``completed`` | ``failed`` |
|
||
``paused``. Each adapter maps its native names to this set.
|
||
- ``progress`` is 0.0–1.0 across the entire job (download + par2 +
|
||
unpack), so a job stalled at the verify step still shows < 1.0.
|
||
"""
|
||
|
||
id: str # SAB nzo_id / NZBGet NZBID
|
||
name: str
|
||
state: str
|
||
progress: float
|
||
size: int # total size in bytes
|
||
downloaded: int # bytes downloaded so far
|
||
download_speed: int # bytes/sec
|
||
eta: Optional[int] = None # seconds, None if unknown
|
||
save_path: Optional[str] = None
|
||
# In-progress / pre-move directory (SAB ``incomplete_path``). Kept
|
||
# SEPARATE from ``save_path`` on purpose: it points at the staging
|
||
# dir SAB uses BEFORE its post-process move, so it must never be
|
||
# treated as the final path on a normal completion. The poll loops
|
||
# only fall back to it as a LAST RESORT — after waiting the full
|
||
# completed-but-no-save_path window — to recover the (#721) case
|
||
# where SAB finished, the files are physically on disk, but the
|
||
# final ``storage`` field never lands. See ``poll_album_download``.
|
||
incomplete_path: Optional[str] = None
|
||
category: Optional[str] = None
|
||
files: Optional[List[str]] = None
|
||
error: Optional[str] = None
|
||
|
||
|
||
@runtime_checkable
|
||
class UsenetClientAdapter(Protocol):
|
||
"""Structural contract every usenet-client adapter implements."""
|
||
|
||
def is_configured(self) -> bool: ...
|
||
|
||
async def check_connection(self) -> bool: ...
|
||
|
||
async def add_nzb(
|
||
self,
|
||
url_or_bytes,
|
||
category: str = "soulsync",
|
||
save_path: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
"""Hand the usenet client either a ``.nzb`` HTTP URL (``str``)
|
||
or the raw payload (``bytes``). Returns the client-side job id
|
||
on success, ``None`` on failure."""
|
||
...
|
||
|
||
async def get_status(self, job_id: str) -> Optional[UsenetStatus]: ...
|
||
|
||
async def get_all(self) -> List[UsenetStatus]: ...
|
||
|
||
async def remove(self, job_id: str, delete_files: bool = False) -> bool: ...
|
||
|
||
async def pause(self, job_id: str) -> bool: ...
|
||
|
||
async def resume(self, job_id: str) -> bool: ...
|