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>
218 lines
8.4 KiB
Python
218 lines
8.4 KiB
Python
"""Album-bundle dispatch for torrent / usenet single-source downloads.
|
|
|
|
Lifted from ``run_full_missing_tracks_process`` so the master
|
|
worker doesn't carry a 90-line inline branch and so the gate logic
|
|
can be unit-tested in isolation.
|
|
|
|
The gate fires only when ALL conditions hold:
|
|
|
|
- Batch is an album-context download (``is_album_download`` flag).
|
|
- Active download source is ``torrent``, ``usenet``, or ``soulseek``.
|
|
In hybrid mode the caller may pass the first configured source as a
|
|
source override; later hybrid sources stay per-track to preserve fallback.
|
|
- Both album-name and artist-name are populated in batch context.
|
|
- The resolved plugin exposes ``download_album_to_staging``.
|
|
|
|
When the gate engages it runs the plugin synchronously (the master
|
|
worker is already on a thread-pool executor) and mirrors the
|
|
plugin's lifecycle payloads into the batch state so the Downloads
|
|
page can render meaningful progress before per-track tasks exist.
|
|
|
|
Return semantics: ``True`` means the gate handled the batch — the
|
|
master worker should stop and not run per-track analysis. ``False``
|
|
means the gate didn't engage (or engaged-and-fell-back) — caller
|
|
continues the normal per-track flow.
|
|
|
|
The ``BatchStateAccess`` Protocol exists so this module doesn't
|
|
import ``download_batches`` from runtime_state directly. The
|
|
caller (master worker) injects accessors so this module stays
|
|
testable without touching live runtime state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional, Protocol
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
# Use the project logger factory so these lines land in app.log under the
|
|
# ``soulsync.*`` namespace the file handler captures. Plain
|
|
# ``logging.getLogger(__name__)`` logs to the console only (the file
|
|
# handler is attached to the ``soulsync`` logger), which is why
|
|
# ``[Album Bundle] flow failed`` showed up in the terminal but never in
|
|
# app.log during the #721 triage.
|
|
logger = get_logger("downloads.album_bundle_dispatch")
|
|
|
|
|
|
class BatchStateAccess(Protocol):
|
|
"""Narrow shim around the batch-state dict ops the dispatch needs.
|
|
|
|
Two methods to keep the surface small:
|
|
- ``update_fields(batch_id, fields)`` — atomic merge into the
|
|
batch dict under tasks_lock.
|
|
- ``mark_failed(batch_id, error)`` — convenience for the failure
|
|
path (sets phase + error + album_bundle_state in one shot).
|
|
"""
|
|
|
|
def update_fields(self, batch_id: str, fields: dict) -> None: ...
|
|
|
|
def mark_failed(self, batch_id: str, error: str) -> None: ...
|
|
|
|
|
|
# Fields the album-bundle progress callback may carry. Anything in
|
|
# this set gets mirrored onto the batch row as ``album_bundle_<key>``
|
|
# so the Downloads page can render it without coupling to the
|
|
# specific payload shape.
|
|
_MIRRORED_KEYS = ('progress', 'release', 'speed', 'downloaded',
|
|
'size', 'seeders', 'grabs', 'count', 'failed')
|
|
|
|
|
|
def is_eligible(
|
|
*,
|
|
mode: str,
|
|
is_album: bool,
|
|
album_name: str,
|
|
artist_name: str,
|
|
) -> bool:
|
|
"""Pure predicate: does this batch even qualify for the album
|
|
flow? Separate from the resolution+run step so tests can pin
|
|
the gate logic without standing up a plugin."""
|
|
if not is_album:
|
|
return False
|
|
if (mode or '').lower() not in ('torrent', 'usenet', 'soulseek'):
|
|
return False
|
|
if not (album_name or '').strip():
|
|
return False
|
|
if not (artist_name or '').strip():
|
|
return False
|
|
return True
|
|
|
|
|
|
def try_dispatch(
|
|
*,
|
|
batch_id: str,
|
|
is_album: bool,
|
|
album_context: Optional[dict],
|
|
artist_context: Optional[dict],
|
|
config_get: Callable[..., Any],
|
|
plugin_resolver: Callable[[str], Optional[Any]],
|
|
state: BatchStateAccess,
|
|
source_override: Optional[str] = None,
|
|
plugin_kwargs: Optional[dict] = None,
|
|
) -> bool:
|
|
"""Attempt the album-bundle flow. Returns ``True`` iff the
|
|
master worker should return early (gate engaged and completed
|
|
— success OR failure). ``False`` means fall through to the
|
|
normal per-track flow.
|
|
|
|
``config_get`` is a callable shaped like ``config_manager.get``;
|
|
``plugin_resolver`` resolves a source-name string to an
|
|
initialised plugin instance (or None); ``state`` is the
|
|
BatchStateAccess shim. Injecting these keeps the module
|
|
dependency-light + unit-testable.
|
|
"""
|
|
mode = (source_override or config_get('download_source.mode', 'soulseek') or 'soulseek').lower()
|
|
album_name = (album_context or {}).get('name') or ''
|
|
artist_name = (artist_context or {}).get('name') or ''
|
|
|
|
if not is_eligible(mode=mode, is_album=is_album,
|
|
album_name=album_name, artist_name=artist_name):
|
|
return False
|
|
|
|
album_name = album_name.strip()
|
|
artist_name = artist_name.strip()
|
|
|
|
plugin = None
|
|
try:
|
|
plugin = plugin_resolver(mode)
|
|
except Exception as exc:
|
|
logger.warning("[Album Bundle] Could not resolve %s plugin: %s", mode, exc)
|
|
|
|
if plugin is None or not hasattr(plugin, 'download_album_to_staging'):
|
|
logger.warning(
|
|
"[Album Bundle] Gate matched but plugin / context unavailable "
|
|
"(mode=%s album=%r artist=%r plugin=%s) — falling back to per-track flow",
|
|
mode, album_name, artist_name,
|
|
type(plugin).__name__ if plugin else None,
|
|
)
|
|
return False
|
|
|
|
staging_root = config_get(
|
|
'download_source.album_bundle_staging_path',
|
|
'storage/album_bundle_staging',
|
|
) or 'storage/album_bundle_staging'
|
|
staging_dir = str(Path(staging_root) / _safe_batch_dirname(batch_id))
|
|
logger.info(
|
|
"[Album Bundle] Engaging %s album flow for '%s' by '%s' -> %s",
|
|
mode, album_name, artist_name, staging_dir,
|
|
)
|
|
state.update_fields(batch_id, {
|
|
'phase': 'album_downloading',
|
|
'album_bundle_state': 'searching',
|
|
'album_bundle_source': mode,
|
|
'album_bundle_staging_path': staging_dir,
|
|
'album_bundle_private_staging': True,
|
|
})
|
|
|
|
def _emit(payload):
|
|
"""Mirror plugin lifecycle into batch state for UI rendering."""
|
|
try:
|
|
fields = {'album_bundle_state': payload.get('state', '')}
|
|
for key in _MIRRORED_KEYS:
|
|
if key in payload:
|
|
fields[f'album_bundle_{key}'] = payload[key]
|
|
state.update_fields(batch_id, fields)
|
|
except Exception as exc:
|
|
logger.debug("[Album Bundle] emit failed: %s", exc)
|
|
|
|
try:
|
|
outcome = plugin.download_album_to_staging(
|
|
album_name, artist_name, staging_dir, _emit,
|
|
**(plugin_kwargs or {}),
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc)
|
|
outcome = {'success': False, 'error': f'Plugin error: {exc}'}
|
|
|
|
if not outcome.get('success'):
|
|
err = outcome.get('error', 'Album bundle download failed')
|
|
if outcome.get('fallback'):
|
|
logger.warning(
|
|
"[Album Bundle] %s flow could not commit for '%s': %s — falling back to per-track flow",
|
|
mode, album_name, err,
|
|
)
|
|
state.update_fields(batch_id, {
|
|
'phase': 'analysis',
|
|
'album_bundle_state': 'fallback',
|
|
'album_bundle_error': err,
|
|
'album_bundle_private_staging': False,
|
|
'album_bundle_staging_path': None,
|
|
})
|
|
return False
|
|
logger.error("[Album Bundle] %s flow failed for '%s': %s",
|
|
mode, album_name, err)
|
|
state.mark_failed(batch_id, err)
|
|
return True
|
|
|
|
logger.info(
|
|
"[Album Bundle] %s staged %d files for '%s' — handing off to per-track staging matcher",
|
|
mode, len(outcome.get('files', [])), album_name,
|
|
)
|
|
state.update_fields(batch_id, {
|
|
'phase': 'analysis',
|
|
'album_bundle_state': 'staged',
|
|
'album_bundle_partial': bool(outcome.get('partial')),
|
|
'album_bundle_expected_count': outcome.get('expected_count'),
|
|
'album_bundle_completed_count': outcome.get('completed_count', len(outcome.get('files', []))),
|
|
})
|
|
# Engaged-and-succeeded: we DON'T early-return because the
|
|
# per-track flow needs to run to create + complete the per-track
|
|
# task rows. Those tasks will hit try_staging_match and pull the
|
|
# files we just staged.
|
|
return False
|
|
|
|
|
|
def _safe_batch_dirname(batch_id: str) -> str:
|
|
safe = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in str(batch_id or 'batch'))
|
|
return safe or 'batch'
|