""" 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