""" 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.download_plugins.types import TrackResult, AlbumResult, DownloadStatus logger = get_logger("lidarr_client") from core.download_plugins.base import DownloadSourcePlugin class LidarrDownloadClient(DownloadSourcePlugin): """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() metadata_profile_id = self._get_metadata_profile_id() add_artist = { 'foreignArtistId': artist_data.get('foreignArtistId', ''), 'artistName': artist_data.get('artistName', ''), 'qualityProfileId': quality_profile_id, 'metadataProfileId': metadata_profile_id, '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 until Lidarr reports the album has imported files. # # Old approach used `for/else` with `break` from the inner queue # loop, but inner-break only escaped the queue iteration — the # outer poll loop kept spinning even after we'd detected # completion. Replaced with an explicit `download_complete` flag # that breaks the OUTER loop once trackFileCount > 0. max_polls = 600 # 10 minutes max download_complete = False 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', ''): # Surface progress while still downloading. status = item.get('status', '').lower() size_left = item.get('sizeleft', 0) size_total = max(item.get('size', 1), 1) progress = 100.0 - (size_left / size_total * 100) with self._download_lock: self.active_downloads[download_id]['progress'] = min(progress, 95.0) if status in ('failed', 'warning'): self._set_error(download_id, f'Lidarr download failed: {status}') return # 'completed' / 'imported' in the queue is # transient — Lidarr drops the item once # import finishes. Don't break here; let the # trackFileCount check below decide. # Authoritative completion signal: album has imported # files. Cheap to call (single GET on a known id) and # works even when the queue record disappeared between # polls. if poll > 5: # Give Lidarr a few seconds to start album_check = self._api_get(f'album/{lidarr_album_id}') if (album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0): download_complete = True break except Exception as e: logger.debug(f"Queue poll error: {e}") time.sleep(1) if not download_complete: self._set_error(download_id, 'Download timed out') return # Step 5: Find and import the wanted track. # # Lidarr grabs whole albums; SoulSync's matched-context # post-processing wants the SPECIFIC track the user # requested. Old behavior copied every track in the album # and reported `imported_files[0]` as `file_path` — which # almost always pointed to track 1, not the user's actual # track. Post-processing then tagged track 1 with the # requested track's metadata. Misfiling guaranteed. # # New behavior: identify the wanted track by title (parsed # from display_name), look up its trackFile via Lidarr's # `track` API, copy ONLY that file. For album-level # dispatches (no specific track in display_name), fall back # to copying the first imported file so existing # album-grab UX still works. with self._download_lock: self.active_downloads[download_id]['progress'] = 96.0 try: wanted_title = self._extract_wanted_track_title(display_name) wanted_src = self._pick_track_file_for_wanted(lidarr_album_id, wanted_title) if wanted_src: # Copy ONLY the matched track. Other album files stay # in Lidarr's root folder and will be cleaned up by # the cleanup step (Step 6) when configured. dst_path = os.path.join(str(self.download_path), os.path.basename(wanted_src)) try: shutil.copy2(wanted_src, dst_path) except Exception as e: self._set_error(download_id, f'Failed to copy wanted track: {e}') return 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'] = dst_path logger.info( f"Lidarr download complete: {display_name} " f"-> {os.path.basename(dst_path)}" ) else: # No specific track wanted (album dispatch) OR fuzzy # match failed. Fall back to copying the first imported # file so something always lands on disk; album-level # callers still get a usable file_path. 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 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] if wanted_title: logger.warning( f"Lidarr: wanted track '{wanted_title}' not matched in album " f"— falling back to first imported file ({len(imported_files)} total)" ) else: logger.info( f"Lidarr album-level download complete: {display_name} " f"({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 as e: logger.debug("Lidarr album cleanup failed: %s", e) 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}") @staticmethod def _extract_wanted_track_title(display_name: str) -> str: """Pull the track title out of the dispatch display string. ``_search_sync`` builds two display shapes: - Track dispatch: ``f"{artist} - {album} - {track_title}"`` - Album dispatch: ``f"{artist} - {album}"`` Need >=3 parts to confidently identify a track. 2-part strings are album-level dispatches — return empty so the caller falls back to copying the first file (correct behavior for "give me the whole album"). Track titles that themselves contain ``' - '`` (e.g. live versions) get rejoined from parts[2:]. """ if not display_name: return '' parts = display_name.split(' - ') if len(parts) < 3: return '' return ' - '.join(parts[2:]).strip() def _pick_track_file_for_wanted(self, lidarr_album_id: int, wanted_title: str) -> Optional[str]: """Find the on-disk path of the imported file matching the wanted track. Walks Lidarr's `track` API to map track titles → trackFileIds, then resolves the trackFileId to a path via `trackfile`. Returns None when the album has no usable wanted-track match (caller falls back to the first imported file in that case so album-level dispatches still work). """ if not wanted_title: return None tracks = self._api_get('track', params={'albumId': lidarr_album_id}) if not tracks or not isinstance(tracks, list): return None # Normalize for case-insensitive fuzzy match. Lidarr's track titles # come from MusicBrainz so they're usually canonical, but # punctuation / casing varies. wanted_norm = self._normalize_for_match(wanted_title) best_track_file_id: Optional[int] = None best_score = 0.0 for t in tracks: track_title = t.get('title', '') or '' track_file_id = t.get('trackFileId') if not track_file_id: continue score = self._title_similarity(wanted_norm, self._normalize_for_match(track_title)) if score > best_score: best_score = score best_track_file_id = track_file_id # 0.7 threshold avoids picking the wrong track when none match # well — caller falls back to first-imported behavior in that case. if best_score < 0.7 or best_track_file_id is None: return None # Resolve trackFileId → path. /trackfile/{id} returns one record. tf = self._api_get(f'trackfile/{best_track_file_id}') if not tf: return None path = tf.get('path', '') if path and os.path.exists(path): return path return None @staticmethod def _normalize_for_match(s: str) -> str: """Lower + strip punctuation + collapse whitespace for fuzzy compare.""" if not s: return '' cleaned = re.sub(r'[^\w\s]', '', s.lower()) return ' '.join(cleaned.split()) @staticmethod def _title_similarity(a: str, b: str) -> float: """Cheap title similarity: equal → 1.0, substring → 0.85, token overlap ratio otherwise. Avoids pulling SequenceMatcher for every comparison since this runs in the hot download path.""" if not a or not b: return 0.0 if a == b: return 1.0 if a in b or b in a: return 0.85 a_tokens = set(a.split()) b_tokens = set(b.split()) if not a_tokens or not b_tokens: return 0.0 intersection = a_tokens & b_tokens union = a_tokens | b_tokens return len(intersection) / len(union) if union else 0.0 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 def _get_metadata_profile_id(self) -> int: """Resolve a usable metadataProfileId for adding artists. Lidarr requires `metadataProfileId` when creating artist records. The default profile is usually id=1, but on installs where the user deleted/recreated profiles, that id may not exist — leading to the API rejecting the artist-add with a 400. Fetch live to pick whatever's actually configured. Falls back to 1 only when the API call fails entirely (preserves previous behavior so this change can't make things worse). """ profiles = self._api_get('metadataprofile') if profiles and isinstance(profiles, list): for p in profiles: pid = p.get('id') if isinstance(pid, int): return pid return 1