Cin's review feedback: the plugin contract was discoverable only from the registry, not from the client files themselves. Reading `youtube_client.py` cold gave no signal that the class participates in the DownloadSourcePlugin contract. Every download client class now inherits DownloadSourcePlugin explicitly: - SoulseekClient(DownloadSourcePlugin) - YouTubeClient(DownloadSourcePlugin) - TidalDownloadClient(DownloadSourcePlugin) - QobuzClient(DownloadSourcePlugin) - HiFiClient(DownloadSourcePlugin) - DeezerDownloadClient(DownloadSourcePlugin) - SoundcloudClient(DownloadSourcePlugin) - LidarrDownloadClient(DownloadSourcePlugin) Adjustments: - core/download_plugins/base.py — moved TrackResult/AlbumResult/ DownloadStatus imports under TYPE_CHECKING since they're only used in type annotations. Without this, clients inheriting the contract create a circular import. - core/download_plugins/__init__.py — drops DownloadPluginRegistry re-export. Importing the package no longer triggers the registry's eager client imports (which would also be circular for clients that import from the package). Callers that need the registry import it directly: `from core.download_plugins.registry import DownloadPluginRegistry`. Suite still green (335 download tests).
717 lines
31 KiB
Python
717 lines
31 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")
|
|
|
|
|
|
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:
|
|
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
|