feat(downloads): album-bundle flow for torrent/usenet single-source mode

Fixes the core architectural mismatch between indexer-based sources
and the per-track search-and-pick contract every other download
plugin satisfies. Prowlarr returns release-level torrents and NZBs;
searching for "Luther (with SZA)" against the GNX album torrent
scores near-zero on track-title similarity. Per-track candidate
validation rejects every result, every track in the batch flips
to not_found. The album-name fallback added in an earlier commit
papers over it for some cases but doesn't fix the fundamental
behavior: the user wanted the whole album.

New album-bundle flow does what the user actually wanted:
1. Gate fires inside core/downloads/master.py BEFORE the per-track
   analysis loop, strictly when the batch has an album context AND
   download_source.mode is 'torrent' or 'usenet' (single-source —
   hybrid stays per-track to preserve fallback to Soulseek / etc.).
2. Plugin's new download_album_to_staging method searches Prowlarr
   ONCE for the album as a whole ('<artist> <album>'), filters to
   the right protocol, runs results through _pick_best_album_release.
3. Picker prefers seeded FLAC over low-seeded MP3, drops single-
   track torrents that snuck in via the 40 MB size floor (single
   tracks are typically ~10 MB), falls back to most-seeded when
   every candidate is below the floor.
4. Picked release goes to the active adapter (qBit / Transmission /
   Deluge for torrent; SAB / NZBGet for usenet). Polls until
   complete with progress mirrored into the batch state so the
   Downloads page can show meaningful status.
5. On completion the existing archive_pipeline walks the save dir
   (extracting archives if any), every audio file gets copied into
   the staging folder via _unique_staging_path so concurrent batches
   don't collide.
6. Gate exits, master worker continues into the normal per-track
   flow. Each track task hits try_staging_match early in the worker
   and finds its file by fuzzy title match — no Prowlarr search
   ever fires per-track, no candidate rejection, files flow through
   the existing post-processing pipeline (tags, AcoustID, library
   import).

Gate is strictly opt-in. Three orthogonal conditions must all hold:
batch_is_album, mode in ('torrent', 'usenet'), and the plugin must
expose download_album_to_staging. Any other source / hybrid mode /
non-album batch flows through the master worker unchanged. The
existing per-track torrent path still works for basic-search
single-track grabs.

- core/download_plugins/torrent.py: download_album_to_staging plus
  _pick_best_album_release and _unique_staging_path helpers (shared
  with the usenet plugin). _poll_album_download mirrors the existing
  poll loop with progress callback emission.
- core/download_plugins/usenet.py: parallel implementation reusing
  the picker + staging helpers. Different state set ('failed' vs
  'error') from the usenet adapter contract.
- core/downloads/master.py: ~90-line gate right after batch context
  loading. Mirrors plugin lifecycle into batch state under
  ``album_bundle_*`` keys so the Downloads page can render progress
  while the torrent/usenet job runs (per-track tasks don't exist
  yet during this phase). Failed bundle download fails the batch
  with a meaningful error; missing plugin / context falls back to
  the per-track flow with a warning.
- tests/test_torrent_usenet_plugins.py: 5 new tests pinning the
  album picker preferences (FLAC over MP3 with comparable size +
  better seeders, size floor drops singles, fallback when all
  small), staging-path collision suffix, and the not-configured
  short-circuit.
pull/671/head
Broque Thomas 5 days ago
parent a2db5382bb
commit c990ce079d

@ -1,7 +1,9 @@
"""TorrentDownloadPlugin — composes Prowlarr search + torrent client
adapter + archive_pipeline into a uniform download source.
Pipeline:
Two flows:
**Per-track flow** (basic search, single-track wishlist)
1. ``search(query)`` calls ``ProwlarrClient.search`` filtered to
``protocol='torrent'`` results, projects releases into
``TrackResult`` / ``AlbumResult`` shaped objects the existing
@ -15,9 +17,20 @@ Pipeline:
3. On completion the thread walks the adapter-reported save path
via ``archive_pipeline.collect_audio_after_extraction`` and
marks the download succeeded with the first audio file as the
primary ``file_path`` (matches Lidarr's single-track-pick
contract picking which specific track to import happens in
post-processing, not here).
primary ``file_path``.
**Album-bundle flow** (album-context batch downloads wired in
``core/downloads/master.py``)
4. ``download_album_to_staging(album, artist, staging_dir)`` does
ONE Prowlarr search for the whole release, picks the best
torrent (prefers FLAC, decent seeders, reasonable size),
downloads it, extracts archives if needed, copies every audio
file into the staging directory. The existing per-track
``try_staging_match`` flow then finds + imports each track by
fuzzy title match against the staged files. Per-track Prowlarr
queries never fire track titles like "Luther (with SZA)"
would match album torrents like "GNX (2024) [FLAC]" at near-
zero confidence and break the per-track dispatch.
Limitations:
- ``save_path`` is the torrent client's view of the disk. If
@ -36,6 +49,7 @@ from __future__ import annotations
import asyncio
import re
import shutil
import threading
import time
import uuid
@ -394,6 +408,201 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
self.active_downloads.pop(did, None)
return True
# ------------------------------------------------------------------
# Album-bundle flow
# ------------------------------------------------------------------
def download_album_to_staging(
self,
album_name: str,
artist_name: str,
staging_dir: str,
progress_callback=None,
) -> Dict[str, Any]:
"""One-shot album download: search Prowlarr for the whole
release, pick the best torrent, fetch it, extract if needed,
copy every audio file into ``staging_dir`` so the existing
``try_staging_match`` flow can hand each track off to the
post-processing pipeline.
``progress_callback`` is called with a dict on each state
change so the batch UI can show download progress without
waiting for the whole thing.
Returns ``{'success': bool, 'files': [paths], 'error': str|None}``.
"""
result: Dict[str, Any] = {'success': False, 'files': [], 'error': None}
if not self.is_configured():
result['error'] = 'Torrent source not configured'
return result
adapter = get_active_torrent_adapter()
if adapter is None or not adapter.is_configured():
result['error'] = 'No active torrent client'
return result
def _emit(state: str, **extra) -> None:
if progress_callback:
payload = {'state': state, **extra}
try:
progress_callback(payload)
except Exception as cb_exc:
logger.debug("[Torrent album] progress callback failed: %s", cb_exc)
# Phase 1: search Prowlarr for the album.
query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
candidates = [r for r in search_results if r.protocol == 'torrent']
if not candidates:
result['error'] = f'No torrent results found for "{query}"'
return result
picked = _pick_best_album_release(candidates)
if picked is None:
result['error'] = 'No suitable torrent candidate after filtering'
return result
download_url = picked.magnet_uri or picked.download_url
logger.info("[Torrent album] Picked '%s' (size=%.1fMB seeders=%s indexer=%s)",
picked.title, picked.size / 1_048_576, picked.seeders, picked.indexer_name)
_emit('queued', release=picked.title, size=picked.size, seeders=picked.seeders)
# Phase 2: hand to adapter.
try:
torrent_id = run_async(adapter.add_torrent(download_url))
except Exception as e:
result['error'] = f'Torrent client refused the release: {e}'
return result
if not torrent_id:
result['error'] = 'Torrent client refused the release'
return result
# Phase 3: poll until complete.
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, torrent_id, picked.title, _emit)
if save_path is None:
result['error'] = 'Torrent download failed or timed out'
return result
# Phase 4: extract + walk + copy to staging.
_emit('staging', release=picked.title)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
return result
staging_path = Path(staging_dir)
staging_path.mkdir(parents=True, exist_ok=True)
copied: List[str] = []
for src in audio_files:
dst = _unique_staging_path(staging_path, src)
try:
shutil.copy2(src, dst)
copied.append(str(dst))
except Exception as e:
logger.warning("[Torrent album] Failed to copy %s -> %s: %s", src, dst, e)
if not copied:
result['error'] = 'No audio files copied to staging'
return result
logger.info("[Torrent album] Staged %d audio files for '%s'", len(copied), album_name)
_emit('staged', count=len(copied))
result['success'] = True
result['files'] = copied
return result
def _poll_album_download(self, adapter, torrent_id, title, emit) -> Optional[str]:
"""Poll the adapter until the torrent is complete. Returns
the save path or ``None`` on timeout / failure."""
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(torrent_id))
except Exception as e:
logger.warning("[Torrent album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Torrent album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'error':
logger.error("[Torrent album] '%s' errored: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Torrent album] '%s' timed out", title)
return None
# ---------------------------------------------------------------------------
# Album-pick helpers
# ---------------------------------------------------------------------------
_QUALITY_SCORE = {'flac': 4, 'ogg': 3, 'aac': 2, 'mp3': 1}
def _pick_best_album_release(candidates) -> Optional[Any]:
"""Pick the single best torrent for an album-bundle download.
Heuristic, in priority order:
1. Reasonable album-ish size (40 MB 3 GB) drops single-track
releases that snuck in and quarantines suspicious giants.
2. Higher seeders > lower (dead torrents = dead downloads).
3. Higher quality (FLAC > AAC > MP3) inferred from title.
4. Larger size as tiebreaker (often = higher bitrate).
"""
MIN_BYTES = 40 * 1024 * 1024
MAX_BYTES = 3 * 1024 * 1024 * 1024
sized = [c for c in candidates if MIN_BYTES <= (c.size or 0) <= MAX_BYTES]
pool = sized or candidates
if not pool:
return None
def _score(c) -> tuple:
seeders = c.seeders or 0
quality = _QUALITY_SCORE.get(_guess_quality_from_title(c.title), 0)
size = c.size or 0
return (seeders, quality, size)
return max(pool, key=_score)
def _unique_staging_path(staging_dir: Path, src: Path) -> Path:
"""Return a destination path inside ``staging_dir`` that doesn't
collide with an existing file. Appends ``_1``, ``_2``, etc. before
the extension when needed."""
dest = staging_dir / src.name
if not dest.exists():
return dest
stem = dest.stem
suffix = dest.suffix
for i in range(1, 1000):
candidate = staging_dir / f"{stem}_{i}{suffix}"
if not candidate.exists():
return candidate
return dest # give up — overwrite
# ---------------------------------------------------------------------------
# Module-level helpers (pure functions — easy to unit-test)

@ -14,6 +14,7 @@ module's docstring for the full pipeline rationale). Differences:
from __future__ import annotations
import shutil
import threading
import time
import uuid
@ -28,7 +29,9 @@ from core.download_plugins.torrent import (
_guess_quality_from_title,
_parse_indexer_id_filter,
_parse_release_title,
_pick_best_album_release,
_row_to_status,
_unique_staging_path,
_COMPLETE_STATES,
_FILENAME_SEP,
_POLL_INTERVAL_SECONDS,
@ -335,3 +338,130 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
if state.startswith('Completed') or state == 'Cancelled':
self.active_downloads.pop(did, None)
return True
# ------------------------------------------------------------------
# Album-bundle flow
# ------------------------------------------------------------------
def download_album_to_staging(
self,
album_name: str,
artist_name: str,
staging_dir: str,
progress_callback=None,
) -> Dict[str, Any]:
"""Usenet sibling of ``TorrentDownloadPlugin.download_album_to_staging``.
See that method's docstring for the contract."""
result: Dict[str, Any] = {'success': False, 'files': [], 'error': None}
if not self.is_configured():
result['error'] = 'Usenet source not configured'
return result
adapter = get_active_usenet_adapter()
if adapter is None or not adapter.is_configured():
result['error'] = 'No active usenet client'
return result
def _emit(state: str, **extra) -> None:
if progress_callback:
try:
progress_callback({'state': state, **extra})
except Exception as cb_exc:
logger.debug("[Usenet album] progress callback failed: %s", cb_exc)
query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
result['error'] = f'No usenet results found for "{query}"'
return result
picked = _pick_best_album_release(candidates)
if picked is None:
result['error'] = 'No suitable NZB candidate after filtering'
return result
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
picked.title, picked.size / 1_048_576, picked.grabs, picked.indexer_name)
_emit('queued', release=picked.title, size=picked.size, grabs=picked.grabs)
try:
job_id = run_async(adapter.add_nzb(picked.download_url))
except Exception as e:
result['error'] = f'Usenet client refused the NZB: {e}'
return result
if not job_id:
result['error'] = 'Usenet client refused the NZB'
return result
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, job_id, picked.title, _emit)
if save_path is None:
result['error'] = 'Usenet download failed or timed out'
return result
_emit('staging', release=picked.title)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
return result
staging_path = Path(staging_dir)
staging_path.mkdir(parents=True, exist_ok=True)
copied: List[str] = []
for src in audio_files:
dst = _unique_staging_path(staging_path, src)
try:
shutil.copy2(src, dst)
copied.append(str(dst))
except Exception as e:
logger.warning("[Usenet album] Failed to copy %s -> %s: %s", src, dst, e)
if not copied:
result['error'] = 'No audio files copied to staging'
return result
logger.info("[Usenet album] Staged %d audio files for '%s'", len(copied), album_name)
_emit('staged', count=len(copied))
result['success'] = True
result['files'] = copied
return result
def _poll_album_download(self, adapter, job_id, title, emit) -> Optional[str]:
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(job_id))
except Exception as e:
logger.warning("[Usenet album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Usenet album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'failed':
logger.error("[Usenet album] '%s' failed: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Usenet album] '%s' timed out", title)
return None

@ -311,6 +311,100 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
if force_download_all:
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
# ════════════════════════════════════════════════════════════════
# ALBUM-BUNDLE GATE for torrent / usenet single-source mode.
#
# Indexer-based sources (torrent / usenet) are release-level — a
# Prowlarr search for "Luther (with SZA)" returns the GNX album
# torrent at near-zero confidence against the track title. The
# per-track search loop fails for every track on the album.
#
# Workaround: when the user is downloading an album AND has
# torrent / usenet selected as the SINGLE active source (not
# hybrid — hybrid stays per-track to preserve fallback to
# Soulseek / streaming sources), do ONE Prowlarr search for the
# whole album, hand the picked release to the torrent / usenet
# client, walk the resulting audio files, and drop them into the
# staging folder. Each per-track task then hits the existing
# ``try_staging_match`` early-return in the per-track worker
# before any Prowlarr search fires, and the normal post-
# processing pipeline imports each matched file.
#
# Gate intentionally narrow: hybrid mode, non-album batches, and
# plugins without ``download_album_to_staging`` (i.e. every
# source other than torrent / usenet) all bypass this branch
# untouched.
# ════════════════════════════════════════════════════════════════
_album_bundle_mode = (deps.config_manager.get('download_source.mode', 'soulseek') or 'soulseek').lower()
_is_torrent_or_usenet = _album_bundle_mode in ('torrent', 'usenet')
if batch_is_album and _is_torrent_or_usenet and batch_album_context and batch_artist_context:
_bundle_album = (batch_album_context.get('name') or '').strip()
_bundle_artist = (batch_artist_context.get('name') or '').strip()
_bundle_plugin = None
try:
_bundle_plugin = deps.download_orchestrator.client(_album_bundle_mode)
except Exception as _exc:
logger.warning("[Album Bundle] Could not resolve %s plugin: %s", _album_bundle_mode, _exc)
if _bundle_album and _bundle_artist and _bundle_plugin and hasattr(_bundle_plugin, 'download_album_to_staging'):
_staging_dir = deps.config_manager.get('import.staging_path', './Staging') or './Staging'
logger.info(
"[Album Bundle] Engaging %s album flow for '%s' by '%s' -> %s",
_album_bundle_mode, _bundle_album, _bundle_artist, _staging_dir,
)
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'album_downloading'
download_batches[batch_id]['album_bundle_state'] = 'searching'
download_batches[batch_id]['album_bundle_source'] = _album_bundle_mode
def _bundle_emit(payload):
"""Mirror the album-download lifecycle into batch state so the
Downloads page can render meaningful status while the torrent /
usenet job runs (the per-track tasks don't exist yet)."""
try:
with tasks_lock:
if batch_id in download_batches:
_row = download_batches[batch_id]
_row['album_bundle_state'] = payload.get('state', '')
for _k in ('progress', 'release', 'speed', 'downloaded', 'size', 'seeders', 'grabs', 'count'):
if _k in payload:
_row[f'album_bundle_{_k}'] = payload[_k]
except Exception as _emit_exc:
logger.debug("[Album Bundle] emit failed: %s", _emit_exc)
try:
_bundle_outcome = _bundle_plugin.download_album_to_staging(
_bundle_album, _bundle_artist, _staging_dir, _bundle_emit,
)
except Exception as _bundle_exc:
logger.exception("[Album Bundle] %s plugin raised: %s", _album_bundle_mode, _bundle_exc)
_bundle_outcome = {'success': False, 'error': f'Plugin error: {_bundle_exc}'}
if not _bundle_outcome.get('success'):
_err = _bundle_outcome.get('error', 'Album bundle download failed')
logger.error("[Album Bundle] %s flow failed for '%s': %s", _album_bundle_mode, _bundle_album, _err)
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'failed'
download_batches[batch_id]['error'] = _err
download_batches[batch_id]['album_bundle_state'] = 'failed'
return
logger.info(
"[Album Bundle] %s staged %d files for '%s' — handing off to per-track staging matcher",
_album_bundle_mode, len(_bundle_outcome.get('files', [])), _bundle_album,
)
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'analysis'
download_batches[batch_id]['album_bundle_state'] = 'staged'
else:
logger.warning(
"[Album Bundle] Gate matched but plugin / context unavailable "
"(mode=%s album=%r artist=%r plugin=%s) — falling back to per-track flow",
_album_bundle_mode, _bundle_album, _bundle_artist,
type(_bundle_plugin).__name__ if _bundle_plugin else None,
)
# Allow duplicate tracks across albums — when enabled, only skip tracks already
# owned in THIS album, not tracks owned in other albums
allow_duplicates = deps.config_manager.get('wishlist.allow_duplicate_tracks', True)

@ -392,6 +392,63 @@ def test_plugins_conform_to_protocol() -> None:
# ---------------------------------------------------------------------------
def test_torrent_album_pick_prefers_seeded_flac(tmp_path: Path) -> None:
"""Album bundle picker prefers high-seeded FLAC over low-seeded MP3
of comparable size protects against picking a dead torrent."""
from core.download_plugins.torrent import _pick_best_album_release
flac = _make_torrent_result(title='Kendrick Lamar - GNX [FLAC]', size=400_000_000, seeders=120)
mp3 = _make_torrent_result(title='Kendrick Lamar - GNX [MP3 320]', size=120_000_000, seeders=5, guid='guid-2')
picked = _pick_best_album_release([flac, mp3])
assert picked is flac
def test_torrent_album_pick_drops_too_small() -> None:
"""Single-track torrents (~10 MB) shouldn't be picked when the user
is downloading a whole album the size floor (40 MB) catches them."""
from core.download_plugins.torrent import _pick_best_album_release
single = _make_torrent_result(title='Kendrick Lamar - HUMBLE', size=10_000_000, seeders=500)
album = _make_torrent_result(title='Kendrick Lamar - DAMN [MP3]', size=120_000_000, seeders=50, guid='guid-2')
picked = _pick_best_album_release([single, album])
assert picked is album
def test_torrent_album_pick_falls_back_when_all_outside_size_range() -> None:
"""If every candidate is below the floor (e.g. all results are
singles), pick the most-seeded one rather than returning None
user still wants a download even if it's a track torrent."""
from core.download_plugins.torrent import _pick_best_album_release
small_a = _make_torrent_result(title='X [MP3]', size=8_000_000, seeders=5)
small_b = _make_torrent_result(title='Y [MP3]', size=9_000_000, seeders=80, guid='guid-2')
picked = _pick_best_album_release([small_a, small_b])
assert picked is small_b
def test_unique_staging_path_handles_collision(tmp_path: Path) -> None:
from core.download_plugins.torrent import _unique_staging_path
src = tmp_path / 'src' / 'track.flac'
src.parent.mkdir()
src.write_bytes(b'fLaC')
dest_dir = tmp_path / 'staging'
dest_dir.mkdir()
# First call returns the natural name.
first = _unique_staging_path(dest_dir, src)
assert first == dest_dir / 'track.flac'
first.write_bytes(b'fLaC')
# Second call picks a non-colliding suffix.
second = _unique_staging_path(dest_dir, src)
assert second == dest_dir / 'track_1.flac'
def test_torrent_album_to_staging_short_circuits_when_not_configured() -> None:
"""The gate must refuse to operate when Prowlarr isn't set up —
every later call would hit the network with empty creds."""
plugin = TorrentDownloadPlugin()
with patch.object(plugin, 'is_configured', return_value=False):
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', '/tmp/staging')
assert outcome['success'] is False
assert 'not configured' in outcome['error'].lower()
def test_registry_includes_torrent_and_usenet() -> None:
"""The registry decides what shows up in the orchestrator's
iteration helpers. If we forget to register a new plugin the

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.0': [
{ unreleased: true },
{ title: 'Album-bundle flow for torrent / usenet downloads', desc: 'fixes the core architectural problem with indexer-based sources. Prowlarr returns release-level torrents — searching per-track for "Luther (with SZA)" against the GNX album torrent scores near-zero and the orchestrator rejects every candidate. New gated flow: when downloading an album AND torrent or usenet is the single active source (not hybrid), SoulSync now does ONE Prowlarr search for the whole release, picks the best torrent (prefers FLAC, high seeders, reasonable size — drops single-track torrents that snuck in), hands it to your torrent / usenet client, walks the resulting audio files (extracting .zip/.rar/.tar if needed), and drops them all into the staging folder. The existing per-track staging matcher then imports each one to the library by fuzzy title match — same path as the Auto-Import flow. Gate is strictly opt-in: per-track flow is completely untouched for hybrid mode, non-album downloads, and every other source. 5 new tests cover the album picker (seeded-FLAC preference, size floor for single-track torrents, fallback when all candidates are small) and the staging path collision handler.' },
{ title: 'Filesystem-access heads-up for torrent / usenet sources', desc: 'new advisory card on the Indexers & Downloaders tab explaining the cleanest setup: point ALL your downloaders (Soulseek, qBittorrent, SABnzbd / NZBGet) at the same download folder. One folder, one mount, everything just works. Bare-metal needs no change; Docker users can reuse the existing ./downloads mount and just configure each client to write there. docker-compose.yml updated to call this out as the easiest path, with optional commented placeholders for users who prefer separate folders per protocol.' },
{ title: 'Torrent and Usenet downloads', desc: 'two new download sources live in the Download Source dropdown: <strong>Torrent Only (via Prowlarr)</strong> and <strong>Usenet Only (via Prowlarr)</strong>. they reuse the Prowlarr + torrent client + usenet client you set up on the Indexers & Downloaders tab. searches go through Prowlarr filtered by protocol, picked releases get handed to your torrent client or usenet client, and the resulting files get walked through archive_pipeline (extracts .zip / .rar / .tar when the client didn\'t already do it) and handed to the matching pipeline. both sources are also available in hybrid mode alongside soulseek / youtube / tidal / etc. one caveat: SoulSync needs read access to the torrent / usenet client\'s save_path — works out of the box for everything-on-one-box setups, but remote downloader hosts will need a future sync step.' },
{ title: 'Archive pipeline module (groundwork for torrent / usenet downloads)', desc: 'new core/archive_pipeline.py — walks a directory for audio files (recursive, case-insensitive extensions), extracts zip / tar / tar.gz / rar / 7z archives in-place (rar and 7z are optional deps that warn but don\'t crash if absent), and rejects any archive member trying to escape the destination via path traversal. shared helper the upcoming torrent and usenet download plugins both consume — usenet downloaders usually auto-extract, but the occasional torrent ships an album in a .rar and SoulSync handles it now. 21 unit tests cover the walker + zip + tar extraction + path-traversal protection.' },

Loading…
Cancel
Save