soulsync/core/usenet_clients/base.py
Tyler Richardson-LaPlume b8384beef9 Fix: Usenet bundle stuck at 99%/100% — SAB reports post-processing in History as non-terminal (#721)
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>
2026-05-29 00:49:02 -04:00

83 lines
3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.01.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: ...