The payoff for the previous five commits. Two new download sources slot into the existing DownloadSourcePlugin contract, backed by Prowlarr (search) + the torrent or usenet client adapter (transfer) + archive_pipeline (post-extract walk). They appear in the Download Source dropdown next to Soulseek / Tidal / Lidarr / etc. and also participate in hybrid mode. Pipeline (both plugins, mirror shape): 1. search(query) → ProwlarrClient.search filtered to the right protocol, projected into TrackResult / AlbumResult shapes the existing search UI already speaks. Filename field encodes the indexer's download URL (or magnet URI for torrents) so download() can recover it later. 2. download() → decodes URL, hands it to the active adapter (qBittorrent / Transmission / Deluge for torrent; SABnzbd / NZBGet for usenet), spawns a background poll thread that tracks progress + reports the adapter-reported save_path. 3. On 'seeding' / 'completed' → archive_pipeline walks the save directory, extracts any archives the downloader didn't already unpack, picks the first audio file as the canonical file_path. Matches the Lidarr client's single-track-pick contract — picking which specific track to import happens in post-processing. - core/download_plugins/torrent.py: TorrentDownloadPlugin + module-level helpers (_decode_filename, _guess_quality_from_title, _parse_indexer_id_filter, _adapter_state_to_display, _row_to_status). Uses get_active_torrent_adapter() so a settings change to the client type takes effect without restart. - core/download_plugins/usenet.py: UsenetDownloadPlugin — parallel shape, reuses the torrent module's helpers. Different enough states (no seeding, no magnet) to warrant its own class but cheap to keep in lockstep. - core/download_plugins/registry.py: register 'torrent' and 'usenet' plugins. Per the registry docstring this is the only wiring point needed — the orchestrator picks them up automatically via the iteration helpers. - webui/index.html: 'Torrent Only (via Prowlarr)' + 'Usenet Only (via Prowlarr)' added to the Download Source dropdown. New redirect card (#prowlarr-source-redirect) explains that the actual config lives on the Indexers & Downloaders tab — shown whenever torrent or usenet is in the active source set. - webui/static/settings.js: HYBRID_SOURCES gets two new entries so hybrid mode can pick them up. updateDownloadSourceUI now toggles the redirect card based on active sources. - tests/test_torrent_usenet_plugins.py: 23 tests covering pure helpers (filename encode/decode round-trip incl. magnet URIs, quality guesser, state mapping), search projection logic (protocol filter, drops without URLs, magnet-preferred-over-URL, filename encoding, neutralised soulseek-specific score fields), is_configured (both prowlarr + adapter required), finalize (picks first audio file, errors on empty dir / missing save_path), clear/get_all lifecycle, DownloadSourcePlugin protocol conformance, and registry membership.
472 lines
18 KiB
Python
472 lines
18 KiB
Python
"""TorrentDownloadPlugin — composes Prowlarr search + torrent client
|
|
adapter + archive_pipeline into a uniform download source.
|
|
|
|
Pipeline:
|
|
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`` (matches Lidarr's single-track-pick
|
|
contract — picking which specific track to import happens in
|
|
post-processing, not here).
|
|
|
|
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 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)
|
|
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,
|
|
artist=None,
|
|
title=result.title,
|
|
album=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=result.title,
|
|
artist=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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 _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'),
|
|
)
|