diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index 414b31d5..faa38c89 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -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) diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index 8442db43..92792305 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -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 diff --git a/core/downloads/master.py b/core/downloads/master.py index 118db159..518797b9 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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) diff --git a/tests/test_torrent_usenet_plugins.py b/tests/test_torrent_usenet_plugins.py index bf2f856b..f830a7ed 100644 --- a/tests/test_torrent_usenet_plugins.py +++ b/tests/test_torrent_usenet_plugins.py @@ -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 diff --git a/webui/static/helper.js b/webui/static/helper.js index 8ae56bc0..080b0df7 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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: Torrent Only (via Prowlarr) and Usenet Only (via Prowlarr). 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.' },