You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/lidarr_download_client.py

715 lines
31 KiB

"""
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()
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:
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}")
@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