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.
717 lines
28 KiB
Python
717 lines
28 KiB
Python
"""TorrentDownloadPlugin — composes Prowlarr search + torrent client
|
||
adapter + archive_pipeline into a uniform download source.
|
||
|
||
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
|
||
search UI already understands. Encodes the indexer's
|
||
``downloadUrl`` (or magnet URI) into the filename so
|
||
``download()`` can recover it.
|
||
2. ``download(username, filename, ...)`` decodes the URL, asks the
|
||
active torrent adapter (qBittorrent, Transmission, or Deluge per
|
||
user's settings) to add it, spawns a background thread that
|
||
polls the adapter for completion.
|
||
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``.
|
||
|
||
**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
|
||
SoulSync runs on a different host than qBit / Trans / Deluge,
|
||
the post-processing pipeline can't see those files. The plugin
|
||
works fine for the all-on-one-box case (most users); remote
|
||
setups will need a future sync step (rclone / SMB / Docker
|
||
bind mount).
|
||
- Track-level metadata isn't available until after download.
|
||
Search results carry only the release title + indexer metadata;
|
||
individual track names are populated when the matching pipeline
|
||
walks the extracted audio files.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
import shutil
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from config.settings import config_manager
|
||
from core.archive_pipeline import collect_audio_after_extraction
|
||
from core.download_plugins.base import DownloadSourcePlugin
|
||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||
from core.prowlarr_client import (
|
||
DEFAULT_MUSIC_CATEGORIES,
|
||
ProwlarrClient,
|
||
ProwlarrSearchResult,
|
||
)
|
||
from core.torrent_clients import get_active_adapter as get_active_torrent_adapter
|
||
from utils.async_helpers import run_async
|
||
from utils.logging_config import get_logger
|
||
|
||
logger = get_logger("download_plugins.torrent")
|
||
|
||
|
||
# Separator used to encode the download URL inside the filename
|
||
# field. Same convention Lidarr / YouTube use for embedding their
|
||
# own opaque identifiers — ``<download_url>||<display>``.
|
||
_FILENAME_SEP = '||'
|
||
|
||
# Adapter states that count as the download being on-disk and
|
||
# safe to walk. ``seeding`` and ``completed`` both mean the
|
||
# bits are there; the user can pause seeding manually if they
|
||
# don't want to keep sharing.
|
||
_COMPLETE_STATES = frozenset(['seeding', 'completed'])
|
||
|
||
# Max seconds the poll thread keeps watching one download before
|
||
# giving up. 6 hours covers slow private trackers without leaking
|
||
# threads forever on dead torrents.
|
||
_POLL_TIMEOUT_SECONDS = 6 * 60 * 60
|
||
|
||
# Poll cadence — torrent state changes slowly; no point hammering
|
||
# the WebUI more than once a second.
|
||
_POLL_INTERVAL_SECONDS = 2.0
|
||
|
||
|
||
class TorrentDownloadPlugin(DownloadSourcePlugin):
|
||
"""Torrent download source backed by Prowlarr + an active
|
||
torrent client adapter."""
|
||
|
||
def __init__(self) -> None:
|
||
self._prowlarr = ProwlarrClient()
|
||
# Track every download we've kicked off. Keyed by our own
|
||
# uuid — NOT the adapter's hash — because the orchestrator
|
||
# owns the lifecycle and we need a stable id even before
|
||
# the adapter has assigned one.
|
||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||
self._lock = threading.Lock()
|
||
self.shutdown_check = None
|
||
|
||
def set_shutdown_check(self, check_callable):
|
||
self.shutdown_check = check_callable
|
||
|
||
def reload_settings(self) -> None:
|
||
self._prowlarr.reload_settings()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Lifecycle
|
||
# ------------------------------------------------------------------
|
||
|
||
def is_configured(self) -> bool:
|
||
if not self._prowlarr.is_configured():
|
||
return False
|
||
adapter = get_active_torrent_adapter()
|
||
return bool(adapter and adapter.is_configured())
|
||
|
||
async def check_connection(self) -> bool:
|
||
if not self._prowlarr.is_configured():
|
||
return False
|
||
adapter = get_active_torrent_adapter()
|
||
if not adapter or not adapter.is_configured():
|
||
return False
|
||
# Probe both sides. A torrent download is useless if either
|
||
# the indexer or the downloader is unreachable.
|
||
prowlarr_ok = await self._prowlarr.check_connection()
|
||
if not prowlarr_ok:
|
||
return False
|
||
return await adapter.check_connection()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Search
|
||
# ------------------------------------------------------------------
|
||
|
||
async def search(
|
||
self,
|
||
query: str,
|
||
timeout: Optional[int] = None,
|
||
progress_callback=None,
|
||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||
if not self._prowlarr.is_configured():
|
||
return ([], [])
|
||
try:
|
||
indexer_ids = _parse_indexer_id_filter()
|
||
results = await self._prowlarr.search(
|
||
query,
|
||
categories=DEFAULT_MUSIC_CATEGORIES,
|
||
indexer_ids=indexer_ids,
|
||
)
|
||
except Exception as e:
|
||
logger.error("Torrent plugin search failed: %s", e)
|
||
return ([], [])
|
||
return self._project_results(results)
|
||
|
||
def _project_results(
|
||
self, results: List[ProwlarrSearchResult]
|
||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||
"""Turn Prowlarr releases into TrackResult / AlbumResult
|
||
shaped objects. One TrackResult + one AlbumResult per
|
||
release — Prowlarr search hits are at the release level,
|
||
not the track level, so we can't synthesise track listings
|
||
without downloading the actual torrent."""
|
||
tracks: List[TrackResult] = []
|
||
albums: List[AlbumResult] = []
|
||
for result in results:
|
||
if result.protocol != 'torrent':
|
||
continue
|
||
download_url = result.magnet_uri or result.download_url
|
||
if not download_url:
|
||
continue
|
||
filename = f"{download_url}{_FILENAME_SEP}{result.title}"
|
||
quality = _guess_quality_from_title(result.title)
|
||
parsed_artist, parsed_title = _parse_release_title(result.title)
|
||
tr = TrackResult(
|
||
username='torrent',
|
||
filename=filename,
|
||
size=result.size,
|
||
bitrate=None,
|
||
duration=None,
|
||
quality=quality,
|
||
# Torrent results don't have per-uploader slot / queue
|
||
# data the way Soulseek does. Fill with neutral values
|
||
# so the quality_score doesn't punish them artificially.
|
||
free_upload_slots=max(1, result.seeders or 0),
|
||
upload_speed=0,
|
||
queue_length=0,
|
||
# Pre-fill artist + title so TrackResult.__post_init__
|
||
# doesn't auto-parse the filename — our filename starts
|
||
# with the indexer download URL, which would otherwise
|
||
# show up as "by download?apikey=..." in the UI.
|
||
artist=parsed_artist or result.indexer_name or 'Torrent',
|
||
title=parsed_title or result.title,
|
||
album=parsed_title or None,
|
||
track_number=None,
|
||
_source_metadata={
|
||
'indexer': result.indexer_name,
|
||
'indexer_id': result.indexer_id,
|
||
'seeders': result.seeders,
|
||
'leechers': result.leechers,
|
||
'grabs': result.grabs,
|
||
'protocol': 'torrent',
|
||
},
|
||
)
|
||
tracks.append(tr)
|
||
albums.append(AlbumResult(
|
||
username='torrent',
|
||
album_path=f"torrent/{result.guid}",
|
||
album_title=parsed_title or result.title,
|
||
artist=parsed_artist or None,
|
||
track_count=1, # unknown until download finishes
|
||
total_size=result.size,
|
||
tracks=[tr],
|
||
dominant_quality=quality,
|
||
year=None,
|
||
))
|
||
return tracks, albums
|
||
|
||
# ------------------------------------------------------------------
|
||
# Download
|
||
# ------------------------------------------------------------------
|
||
|
||
async def download(
|
||
self,
|
||
username: str,
|
||
filename: str,
|
||
file_size: int = 0,
|
||
) -> Optional[str]:
|
||
if not self.is_configured():
|
||
return None
|
||
download_url, display_name = _decode_filename(filename)
|
||
if not download_url:
|
||
logger.error("Torrent download missing URL in filename: %r", filename)
|
||
return None
|
||
|
||
download_id = str(uuid.uuid4())
|
||
with self._lock:
|
||
self.active_downloads[download_id] = {
|
||
'id': download_id,
|
||
'filename': filename,
|
||
'username': 'torrent',
|
||
'display_name': display_name,
|
||
'state': 'Initializing',
|
||
'progress': 0.0,
|
||
'size': file_size,
|
||
'transferred': 0,
|
||
'speed': 0,
|
||
'file_path': None,
|
||
'torrent_hash': None,
|
||
'error': None,
|
||
}
|
||
|
||
thread = threading.Thread(
|
||
target=self._download_thread,
|
||
args=(download_id, download_url, display_name),
|
||
daemon=True,
|
||
name=f'torrent-dl-{download_id[:8]}',
|
||
)
|
||
thread.start()
|
||
return download_id
|
||
|
||
def _download_thread(self, download_id: str, download_url: str, display_name: str) -> None:
|
||
"""Background worker: hand the URL to the active adapter,
|
||
poll until done, then walk the resulting directory."""
|
||
adapter = get_active_torrent_adapter()
|
||
if adapter is None or not adapter.is_configured():
|
||
self._mark_error(download_id, "No torrent client configured")
|
||
return
|
||
|
||
try:
|
||
torrent_hash = run_async(adapter.add_torrent(download_url))
|
||
except Exception as e:
|
||
self._mark_error(download_id, f"add_torrent failed: {e}")
|
||
return
|
||
if not torrent_hash:
|
||
self._mark_error(download_id, "Torrent client refused the URL")
|
||
return
|
||
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is not None:
|
||
row['torrent_hash'] = torrent_hash
|
||
row['state'] = 'InProgress, Downloading'
|
||
|
||
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
|
||
try:
|
||
status = run_async(adapter.get_status(torrent_hash))
|
||
except Exception as e:
|
||
logger.warning("Torrent poll error for %s: %s", torrent_hash, e)
|
||
status = None
|
||
|
||
if status is None:
|
||
# Adapter forgot about the torrent — probably user-removed.
|
||
self._mark_error(download_id, "Torrent disappeared from client")
|
||
return
|
||
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is not None:
|
||
row['progress'] = status.progress * 100.0
|
||
row['transferred'] = status.downloaded
|
||
row['speed'] = status.download_speed
|
||
row['size'] = status.size or row.get('size', 0)
|
||
row['state'] = _adapter_state_to_display(status.state)
|
||
row['error'] = status.error
|
||
if status.save_path:
|
||
last_save_path = status.save_path
|
||
|
||
if status.state in _COMPLETE_STATES:
|
||
self._finalize_download(download_id, last_save_path)
|
||
return
|
||
if status.state == 'error':
|
||
self._mark_error(download_id, status.error or "Torrent client reported error")
|
||
return
|
||
|
||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||
|
||
self._mark_error(download_id, "Torrent download timed out")
|
||
|
||
def _finalize_download(self, download_id: str, save_path: Optional[str]) -> None:
|
||
"""Adapter said complete. Walk the directory + pick the
|
||
first audio file as the canonical ``file_path``."""
|
||
if not save_path:
|
||
self._mark_error(download_id, "Torrent completed but no save_path reported")
|
||
return
|
||
try:
|
||
audio_files = collect_audio_after_extraction(Path(save_path))
|
||
except Exception as e:
|
||
self._mark_error(download_id, f"Post-extract walk failed: {e}")
|
||
return
|
||
if not audio_files:
|
||
self._mark_error(download_id, f"No audio files found in {save_path}")
|
||
return
|
||
primary = audio_files[0]
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is not None:
|
||
row['state'] = 'Completed, Succeeded'
|
||
row['progress'] = 100.0
|
||
row['file_path'] = str(primary)
|
||
logger.info("Torrent download complete: %s -> %s (%d audio files)",
|
||
download_id[:8], primary.name, len(audio_files))
|
||
|
||
def _mark_error(self, download_id: str, message: str) -> None:
|
||
logger.error("Torrent download %s failed: %s", download_id[:8], message)
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is not None:
|
||
row['state'] = 'Completed, Errored'
|
||
row['error'] = message
|
||
|
||
# ------------------------------------------------------------------
|
||
# Status / lifecycle
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||
with self._lock:
|
||
rows = list(self.active_downloads.values())
|
||
return [_row_to_status(r) for r in rows]
|
||
|
||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is None:
|
||
return None
|
||
return _row_to_status(row)
|
||
|
||
async def cancel_download(
|
||
self,
|
||
download_id: str,
|
||
username: Optional[str] = None,
|
||
remove: bool = False,
|
||
) -> bool:
|
||
adapter = get_active_torrent_adapter()
|
||
with self._lock:
|
||
row = self.active_downloads.get(download_id)
|
||
torrent_hash = row.get('torrent_hash') if row else None
|
||
if adapter and torrent_hash:
|
||
try:
|
||
await adapter.remove(torrent_hash, delete_files=remove)
|
||
except Exception as e:
|
||
logger.warning("Torrent cancel via adapter failed: %s", e)
|
||
with self._lock:
|
||
if remove:
|
||
self.active_downloads.pop(download_id, None)
|
||
else:
|
||
row = self.active_downloads.get(download_id)
|
||
if row is not None:
|
||
row['state'] = 'Cancelled'
|
||
return True
|
||
|
||
async def clear_all_completed_downloads(self) -> bool:
|
||
with self._lock:
|
||
for did in list(self.active_downloads.keys()):
|
||
state = self.active_downloads[did].get('state', '')
|
||
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]:
|
||
"""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)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _decode_filename(filename: str) -> Tuple[Optional[str], str]:
|
||
"""Pull the encoded download URL out of the ``filename`` string.
|
||
Returns ``(url, display_name)``. ``url`` is None when the string
|
||
has no separator."""
|
||
if not filename or _FILENAME_SEP not in filename:
|
||
return (None, filename or '')
|
||
url, display = filename.split(_FILENAME_SEP, 1)
|
||
return (url, display)
|
||
|
||
|
||
def _parse_release_title(title: str) -> Tuple[str, str]:
|
||
"""Split a release title into ``(artist, title)`` using the
|
||
``Artist - Title`` / ``Artist - Album`` convention almost every
|
||
indexer follows. Returns ``('', title)`` when no dash is found.
|
||
|
||
Without this, ``TrackResult.__post_init__`` runs the bare
|
||
filename through ``parse_filename_metadata`` — and our filename
|
||
starts with the indexer's download URL, so the auto-parser
|
||
extracts garbage like ``download?apikey=...`` as the artist
|
||
and shows it in the search-result UI's "by" line. Pre-filling
|
||
the artist field short-circuits the auto-parse.
|
||
"""
|
||
if not title:
|
||
return ('', '')
|
||
# Strip common quality / format tags so the dash split doesn't
|
||
# eat them — "Artist - Album [FLAC] (2020)" → "Artist", "Album".
|
||
cleaned = re.sub(r'\s*[\[\(][^\]\)]*[\]\)]\s*$', '', title.strip())
|
||
# Look for the FIRST " - " (or "-" surrounded by content). Some
|
||
# release titles have multiple dashes (subtitle dashes); the
|
||
# first split is the artist/work boundary.
|
||
parts = re.split(r'\s+-\s+|\s+-(?=\S)|(?<=\S)-\s+', cleaned, maxsplit=1)
|
||
if len(parts) == 2:
|
||
artist = parts[0].strip()
|
||
rest = parts[1].strip()
|
||
# Reject obvious non-artist prefixes (URLs, hashes, single
|
||
# punctuation) so we don't propagate garbage.
|
||
if artist and not re.match(r'^https?:|^[a-f0-9]{32,}$', artist):
|
||
return (artist, rest or cleaned)
|
||
return ('', cleaned)
|
||
|
||
|
||
def _guess_quality_from_title(title: str) -> str:
|
||
"""Read the quality hint from a release title — most music
|
||
torrents put the encoding right in the name (FLAC, MP3 320,
|
||
etc.). Falls back to ``'mp3'`` so quality_score doesn't crash."""
|
||
if not title:
|
||
return 'mp3'
|
||
lower = title.lower()
|
||
if 'flac' in lower:
|
||
return 'flac'
|
||
if re.search(r'\b24[\s-]?bit\b', lower) or 'hi-?res' in lower:
|
||
return 'flac'
|
||
if 'aac' in lower:
|
||
return 'aac'
|
||
if 'ogg' in lower:
|
||
return 'ogg'
|
||
return 'mp3'
|
||
|
||
|
||
def _parse_indexer_id_filter() -> List[int]:
|
||
"""Read the comma-separated indexer-ID allowlist from config.
|
||
Empty list = search every enabled indexer."""
|
||
raw = (config_manager.get('prowlarr.indexer_ids', '') or '').strip()
|
||
if not raw:
|
||
return []
|
||
out: List[int] = []
|
||
for chunk in raw.split(','):
|
||
chunk = chunk.strip()
|
||
if not chunk:
|
||
continue
|
||
try:
|
||
out.append(int(chunk))
|
||
except ValueError:
|
||
continue
|
||
return out
|
||
|
||
|
||
def _adapter_state_to_display(state: str) -> str:
|
||
"""Translate the adapter-uniform state strings into the
|
||
``'InProgress, Downloading'`` / ``'Completed, Succeeded'``
|
||
style the existing UI expects (matches Soulseek + Lidarr)."""
|
||
mapping = {
|
||
'queued': 'Queued',
|
||
'downloading': 'InProgress, Downloading',
|
||
'stalled': 'InProgress, Stalled',
|
||
'seeding': 'Completed, Succeeded',
|
||
'completed': 'Completed, Succeeded',
|
||
'paused': 'Paused',
|
||
'error': 'Completed, Errored',
|
||
}
|
||
return mapping.get(state, state.title())
|
||
|
||
|
||
def _row_to_status(row: Dict[str, Any]) -> DownloadStatus:
|
||
return DownloadStatus(
|
||
id=row['id'],
|
||
filename=row['filename'],
|
||
username=row['username'],
|
||
state=row.get('state', 'Unknown'),
|
||
progress=float(row.get('progress', 0.0)),
|
||
size=int(row.get('size', 0)),
|
||
transferred=int(row.get('transferred', 0)),
|
||
speed=int(row.get('speed', 0)),
|
||
time_remaining=None,
|
||
file_path=row.get('file_path'),
|
||
)
|