soulsync/core/lidarr_download_client.py
Broque Thomas fc38ec4787 Add Lidarr as 7th download source and validate music video path
Lidarr integration:
- New core/lidarr_download_client.py with full interface parity
  (search, download, status, cancel — same as Qobuz/Tidal/HiFi)
- Registered in download orchestrator with source routing
- Settings: URL + API key on Downloads tab with connection test
- Available as standalone source or in Hybrid mode priority order
- API key encrypted at rest
- All streaming source checks updated to include 'lidarr'

Lidarr downloads full albums via Usenet/torrent — SoulSync imports
only the tracks it needs and discards the rest.

Music video path validation:
- Empty/unconfigured path returns clear error instead of silent failure
- Write permission test before starting download
- Default changed from './MusicVideos' to empty (must be configured)
2026-04-11 07:59:12 -07:00

534 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(f'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:
return DownloadStatus(
id=dl['id'],
filename=dl['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