soulsync/core/lidarr_download_client.py
Broque Thomas d9217237d2 Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs
PR #340 added ruff to the build-and-test.yml CI gate, which surfaced
286 pre-existing lint errors. Left unfixed, every feature branch push
fails CI. This commit resolves all of them so CI goes green and
contributors can actually land work.

Auto-fixes (248 of 286): removed unused f-string prefixes (F541),
renamed unused loop control variables with underscore prefix (B007),
removed duplicate imports (F811).

Manually fixed 10 latent bugs ruff caught (all wrapped in try/except
today, silently failing):

- music_database.py: _add_discovery_tables() called undefined
  conn.commit() — would have crashed the iTunes-support migration
  for existing databases. Now uses cursor.connection.commit().
- web_server.py settings GET: referenced undefined download_orchestrator
  when it should be soulseek_client. Feature (_source_status on the
  settings payload) was silently missing for UI auto-disable logic.
- web_server.py _process_wishlist_automatically: active_server
  undefined in track-ownership check. Auto-wishlist was falling
  through to the error handler and re-downloading owned tracks.
- web_server.py start_wishlist_missing_downloads: same active_server
  bug in the manual wishlist path.
- web_server.py _process_failed_tracks_to_wishlist_exact: emitted
  wishlist_item_added automation event with undefined artist_name
  and track. Automation event silently never fired correctly.
- web_server.py discovery metadata enrichment: referenced cache
  without calling get_metadata_cache() first. Track enrichment from
  cached API responses was silently skipped.
- web_server.py Beatport discovery worker: wing-it fallback branch
  used undefined successful_discoveries variable. Wing-it counter
  never incremented correctly. Now uses state['spotify_matches']
  consistently with the rest of the function.
- web_server.py _run_full_missing_tracks_process: stale import json
  mid-function shadowed the module-level import, making an earlier
  json.dumps() call reference an unbound local (F823).
- web_server.py discovery loop: platform loop variable shadowed
  the module-level platform import (F402).
- core/watchlist_scanner.py: 7 lambda captures of loop variables
  (B023 classic Python closure-in-loop bug) now bind at creation.

No existing tests had to change. Full suite stays at 263 passed.
2026-04-21 13:30:52 -07:00

538 lines
22 KiB
Python

"""
Lidarr Download Client
Download source using Lidarr's API for Usenet/torrent downloads.
This client provides:
- Album search via Lidarr's metadata lookup
- Download triggering via Lidarr's indexer/download client pipeline
- Progress monitoring via Lidarr's queue API
- Drop-in replacement compatible with Soulseek interface
Requires a running Lidarr instance with configured indexers and download clients.
Lidarr downloads full albums — SoulSync imports only the tracks it needs.
"""
import os
import re
import time
import asyncio
import uuid
import shutil
import threading
from typing import List, Optional, Dict, Any, Tuple
from pathlib import Path
import requests as http_requests
from utils.logging_config import get_logger
from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("lidarr_client")
class LidarrDownloadClient:
"""Lidarr download client — uses Lidarr as a download source for Usenet/torrent content.
Implements the same interface as SoulseekClient, QobuzClient, TidalDownloadClient
for seamless integration with the download orchestrator.
"""
def __init__(self, download_path: str = None):
if download_path is None:
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._download_lock = threading.Lock()
self.shutdown_check = None
self._load_config()
def _load_config(self):
self._url = (config_manager.get('lidarr_download.url', '') or '').rstrip('/')
self._api_key = config_manager.get('lidarr_download.api_key', '') or ''
self._root_folder = config_manager.get('lidarr_download.root_folder', '') or ''
self._quality_profile = config_manager.get('lidarr_download.quality_profile', 'Any') or 'Any'
self._cleanup = config_manager.get('lidarr_download.cleanup_after_import', True)
def set_shutdown_check(self, check_callable):
self.shutdown_check = check_callable
def reload_settings(self):
self._load_config()
logger.info("Lidarr settings reloaded")
# ==================== Interface Methods ====================
def is_configured(self) -> bool:
return bool(self._url and self._api_key)
def is_available(self) -> bool:
return self.is_configured()
async def check_connection(self) -> bool:
if not self.is_configured():
return False
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._check_connection_sync)
except Exception:
return False
def _check_connection_sync(self) -> bool:
try:
data = self._api_get('system/status')
return data is not None and 'version' in data
except Exception as e:
logger.error(f"Lidarr connection check failed: {e}")
return False
async def search(self, query: str, timeout: int = None,
progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""Search Lidarr for albums matching the query.
Returns individual tracks from matched albums as TrackResult objects,
plus AlbumResult objects for album-level matching.
"""
if not self.is_configured():
return ([], [])
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._search_sync, query)
except Exception as e:
logger.error(f"Lidarr search failed: {e}")
return ([], [])
def _search_sync(self, query: str) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""Synchronous search implementation."""
try:
# Search for albums
albums_data = self._api_get('album/lookup', params={'term': query})
if not albums_data:
return ([], [])
track_results = []
album_results = []
for album in albums_data[:10]: # Limit to 10 albums
album_title = album.get('title', '')
artist_data = album.get('artist', {})
artist_name = artist_data.get('artistName', '')
foreign_album_id = album.get('foreignAlbumId', '')
release_date = album.get('releaseDate', '')
year = release_date[:4] if release_date and len(release_date) >= 4 else ''
# Get tracks from the album's releases
releases = album.get('releases', [])
tracks_in_album = []
for release in releases:
media_list = release.get('media', [])
for media in media_list:
for track in media.get('tracks', []):
track_title = track.get('title', '')
track_number = track.get('trackNumber', 0) or track.get('absoluteTrackNumber', 0)
duration_ms = (track.get('duration', '') or 0)
if isinstance(duration_ms, str):
# Lidarr returns duration as "HH:MM:SS" or milliseconds
try:
duration_ms = int(duration_ms)
except ValueError:
duration_ms = 0
# Encode album info in filename for later retrieval
display = f"{artist_name} - {album_title} - {track_title}"
filename = f"{foreign_album_id}||{display}"
tr = TrackResult(
username='lidarr',
filename=filename,
size=0,
bitrate=1411, # Assume lossless
duration=duration_ms,
quality='flac',
free_upload_slots=999,
upload_speed=999999,
queue_length=0,
artist=artist_name,
title=track_title,
album=album_title,
track_number=track_number,
)
track_results.append(tr)
tracks_in_album.append(tr)
# If no track-level data, create album-level entry
if not tracks_in_album:
display = f"{artist_name} - {album_title}"
filename = f"{foreign_album_id}||{display}"
tr = TrackResult(
username='lidarr',
filename=filename,
size=0,
bitrate=1411,
duration=0,
quality='flac',
free_upload_slots=999,
upload_speed=999999,
queue_length=0,
artist=artist_name,
title=album_title,
album=album_title,
track_number=None,
)
track_results.append(tr)
# Build AlbumResult
if tracks_in_album:
ar = AlbumResult(
username='lidarr',
album_path=f"lidarr/{foreign_album_id}",
album_title=album_title,
artist=artist_name,
track_count=len(tracks_in_album),
total_size=0,
tracks=tracks_in_album,
dominant_quality='flac',
year=year,
)
album_results.append(ar)
logger.info(f"Lidarr search '{query}': {len(track_results)} tracks, {len(album_results)} albums")
return (track_results, album_results)
except Exception as e:
logger.error(f"Lidarr search error: {e}")
return ([], [])
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
"""Trigger a download via Lidarr.
Extracts the album ID from the filename (id||display pattern),
adds the album to Lidarr if needed, and triggers a search.
"""
if not self.is_configured():
return None
download_id = str(uuid.uuid4())
# Parse album ID from filename
album_foreign_id = ''
display_name = filename
if '||' in filename:
parts = filename.split('||', 1)
album_foreign_id = parts[0]
display_name = parts[1]
with self._download_lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename,
'display_name': display_name,
'username': 'lidarr',
'state': 'Initializing',
'progress': 0.0,
'file_path': None,
'album_foreign_id': album_foreign_id,
}
# Start background download thread
thread = threading.Thread(
target=self._download_thread_worker,
args=(download_id, album_foreign_id, display_name),
daemon=True,
name=f'lidarr-dl-{download_id[:8]}',
)
thread.start()
return download_id
def _download_thread_worker(self, download_id: str, album_foreign_id: str, display_name: str):
"""Background worker that manages the Lidarr download lifecycle."""
try:
# Step 1: Look up the album in Lidarr
with self._download_lock:
self.active_downloads[download_id]['state'] = 'Initializing'
album_data = self._api_get('album/lookup', params={'term': f'lidarr:{album_foreign_id}'})
if not album_data:
# Try searching by the display name
album_data = self._api_get('album/lookup', params={'term': display_name})
if not album_data:
self._set_error(download_id, 'Album not found in Lidarr')
return
album = album_data[0] if isinstance(album_data, list) else album_data
artist_data = album.get('artist', {})
# Step 2: Ensure artist exists in Lidarr
artist_id = artist_data.get('id')
if not artist_id:
# Add artist to Lidarr
try:
root_folder = self._get_root_folder()
quality_profile_id = self._get_quality_profile_id()
add_artist = {
'foreignArtistId': artist_data.get('foreignArtistId', ''),
'artistName': artist_data.get('artistName', ''),
'qualityProfileId': quality_profile_id,
'metadataProfileId': 1,
'rootFolderPath': root_folder,
'monitored': False,
'addOptions': {'monitor': 'none', 'searchForMissingAlbums': False},
}
result = self._api_post('artist', data=add_artist)
artist_id = result.get('id') if result else None
except Exception as e:
logger.warning(f"Failed to add artist to Lidarr: {e}")
# Step 3: Add album and trigger search
with self._download_lock:
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
self.active_downloads[download_id]['progress'] = 5.0
try:
root_folder = self._get_root_folder()
quality_profile_id = self._get_quality_profile_id()
add_album = {
'foreignAlbumId': album.get('foreignAlbumId', ''),
'title': album.get('title', ''),
'artistId': artist_id,
'qualityProfileId': quality_profile_id,
'rootFolderPath': root_folder,
'monitored': True,
'addOptions': {'searchForNewAlbum': True},
}
# Check if album already exists
existing = self._api_get('album', params={'foreignAlbumId': album.get('foreignAlbumId', '')})
if existing and isinstance(existing, list) and len(existing) > 0:
lidarr_album_id = existing[0].get('id')
# Trigger search for existing album
self._api_post('command', data={
'name': 'AlbumSearch',
'albumIds': [lidarr_album_id],
})
else:
result = self._api_post('album', data=add_album)
lidarr_album_id = result.get('id') if result else None
if not lidarr_album_id:
self._set_error(download_id, 'Failed to add album to Lidarr')
return
except Exception as e:
self._set_error(download_id, f'Failed to trigger download: {e}')
return
# Step 4: Poll queue for progress
max_polls = 600 # 10 minutes max
for poll in range(max_polls):
if self.shutdown_check and self.shutdown_check():
self._set_error(download_id, 'Server shutting down')
return
with self._download_lock:
if download_id not in self.active_downloads:
return
if self.active_downloads[download_id]['state'] == 'Cancelled':
return
try:
queue = self._api_get('queue', params={'includeAlbum': 'true'})
if queue and 'records' in queue:
for item in queue['records']:
item_album = item.get('album', {})
if item_album.get('foreignAlbumId') == album.get('foreignAlbumId', ''):
# Found our download in the queue
status = item.get('status', '').lower()
progress = 100.0 - (item.get('sizeleft', 0) / max(item.get('size', 1), 1) * 100)
with self._download_lock:
self.active_downloads[download_id]['progress'] = min(progress, 95.0)
if status in ('completed', 'imported'):
break
elif status in ('failed', 'warning'):
self._set_error(download_id, f'Lidarr download failed: {status}')
return
else:
# Not in queue — might be completed already
# Check if album has files
if poll > 10: # Give it at least 10 seconds
album_check = self._api_get(f'album/{lidarr_album_id}')
if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0:
break
else:
# No queue data — check if completed
if poll > 10:
album_check = self._api_get(f'album/{lidarr_album_id}')
if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0:
break
except Exception as e:
logger.debug(f"Queue poll error: {e}")
time.sleep(1)
else:
self._set_error(download_id, 'Download timed out')
return
# Step 5: Find and import downloaded files
with self._download_lock:
self.active_downloads[download_id]['progress'] = 96.0
try:
# Get track files from Lidarr
track_files = self._api_get('trackfile', params={'albumId': lidarr_album_id})
if not track_files:
self._set_error(download_id, 'No files found after download')
return
# Copy files to SoulSync's download path
imported_files = []
for tf in track_files:
src_path = tf.get('path', '')
if src_path and os.path.exists(src_path):
dst_path = os.path.join(str(self.download_path), os.path.basename(src_path))
try:
shutil.copy2(src_path, dst_path)
imported_files.append(dst_path)
except Exception as e:
logger.warning(f"Failed to copy {src_path}: {e}")
if imported_files:
with self._download_lock:
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
self.active_downloads[download_id]['progress'] = 100.0
self.active_downloads[download_id]['file_path'] = imported_files[0]
logger.info(f"Lidarr download complete: {display_name} ({len(imported_files)} files)")
else:
self._set_error(download_id, 'Failed to import files')
except Exception as e:
self._set_error(download_id, f'Import failed: {e}')
return
# Step 6: Cleanup — remove from Lidarr if configured
if self._cleanup:
try:
self._api_delete(f'album/{lidarr_album_id}', params={'deleteFiles': 'false'})
logger.debug(f"Cleaned up album {lidarr_album_id} from Lidarr")
except Exception:
pass
except Exception as e:
logger.error(f"Lidarr download thread failed: {e}")
self._set_error(download_id, str(e))
def _set_error(self, download_id: str, error: str):
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored'
self.active_downloads[download_id]['error'] = error
logger.error(f"Lidarr download error: {error}")
async def get_all_downloads(self) -> List[DownloadStatus]:
with self._download_lock:
return [self._to_status(dl) for dl in self.active_downloads.values()]
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
with self._download_lock:
dl = self.active_downloads.get(download_id)
return self._to_status(dl) if dl else None
def _to_status(self, dl: Dict) -> DownloadStatus:
filename = dl['filename']
# Append error info to filename for UI visibility when errored
if dl['state'] == 'Errored' and dl.get('error'):
filename = f"{filename}{dl['error']}"
return DownloadStatus(
id=dl['id'],
filename=filename,
username='lidarr',
state=dl['state'],
progress=dl['progress'],
size=0,
transferred=0,
speed=0,
file_path=dl.get('file_path'),
)
async def cancel_download(self, download_id: str, username: str = None,
remove: bool = False) -> bool:
with self._download_lock:
if download_id in self.active_downloads:
if remove:
del self.active_downloads[download_id]
else:
self.active_downloads[download_id]['state'] = 'Cancelled'
return True
return False
async def clear_all_completed_downloads(self) -> bool:
with self._download_lock:
self.active_downloads = {
k: v for k, v in self.active_downloads.items()
if v['state'] not in ('Completed, Succeeded', 'Errored', 'Cancelled')
}
return True
# ==================== Lidarr API Helpers ====================
def _api_get(self, endpoint: str, params: dict = None) -> Optional[Any]:
try:
url = f"{self._url}/api/v1/{endpoint}"
headers = {'X-Api-Key': self._api_key}
resp = http_requests.get(url, headers=headers, params=params, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.debug(f"Lidarr API GET {endpoint} failed: {e}")
return None
def _api_post(self, endpoint: str, data: dict = None) -> Optional[Any]:
try:
url = f"{self._url}/api/v1/{endpoint}"
headers = {'X-Api-Key': self._api_key, 'Content-Type': 'application/json'}
resp = http_requests.post(url, headers=headers, json=data, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.debug(f"Lidarr API POST {endpoint} failed: {e}")
return None
def _api_delete(self, endpoint: str, params: dict = None) -> bool:
try:
url = f"{self._url}/api/v1/{endpoint}"
headers = {'X-Api-Key': self._api_key}
resp = http_requests.delete(url, headers=headers, params=params, timeout=15)
return resp.ok
except Exception:
return False
def _get_root_folder(self) -> str:
if self._root_folder:
return self._root_folder
# Fetch from Lidarr
folders = self._api_get('rootfolder')
if folders and isinstance(folders, list) and len(folders) > 0:
return folders[0].get('path', '/music')
return '/music'
def _get_quality_profile_id(self) -> int:
profiles = self._api_get('qualityprofile')
if not profiles:
return 1
# Find matching profile by name, or use first
for p in profiles:
if p.get('name', '').lower() == self._quality_profile.lower():
return p['id']
return profiles[0].get('id', 1) if profiles else 1