diff --git a/config/settings.py b/config/settings.py index af1844b3..a43131d9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -79,6 +79,7 @@ class ConfigManager: # Download sources 'soulseek.api_key', 'deezer_download.arl', + 'lidarr_download.api_key', # Enrichment services 'listenbrainz.token', 'acoustid.api_key', @@ -409,6 +410,13 @@ class ConfigManager: "hifi_download": { "quality": "lossless", # Options: "low", "high", "lossless", "hires" }, + "lidarr_download": { + "url": "", + "api_key": "", + "root_folder": "", + "quality_profile": "Any", + "cleanup_after_import": True, + }, "listenbrainz": { "base_url": "", "token": "", @@ -462,7 +470,7 @@ class ConfigManager: }, "library": { "music_paths": [], - "music_videos_path": "./MusicVideos" + "music_videos_path": "" }, "scripts": { "path": "./scripts", diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index bc4d4c85..e718c5bd 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -24,6 +24,7 @@ from core.tidal_download_client import TidalDownloadClient from core.qobuz_client import QobuzClient from core.hifi_client import HiFiClient from core.deezer_download_client import DeezerDownloadClient +from core.lidarr_download_client import LidarrDownloadClient logger = get_logger("download_orchestrator") @@ -47,6 +48,7 @@ class DownloadOrchestrator: self.qobuz = self._safe_init('Qobuz', QobuzClient) self.hifi = self._safe_init('HiFi', HiFiClient) self.deezer_dl = self._safe_init('Deezer', DeezerDownloadClient) + self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient) if self._init_failures: logger.warning(f"⚠️ Download clients failed to initialize: {', '.join(self._init_failures)}") @@ -96,7 +98,8 @@ class DownloadOrchestrator: def _client(self, name): """Get a client by name, returning None if not initialized.""" return {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, - 'qobuz': self.qobuz, 'hifi': self.hifi, 'deezer_dl': self.deezer_dl}.get(name) + 'qobuz': self.qobuz, 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, + 'lidarr': self.lidarr}.get(name) def is_configured(self) -> bool: """ @@ -117,7 +120,8 @@ class DownloadOrchestrator: return {name: (c.is_configured() if c else False) for name, c in [('soulseek', self.soulseek), ('youtube', self.youtube), ('tidal', self.tidal), ('qobuz', self.qobuz), - ('hifi', self.hifi), ('deezer_dl', self.deezer_dl)]} + ('hifi', self.hifi), ('deezer_dl', self.deezer_dl), + ('lidarr', self.lidarr)]} async def check_connection(self) -> bool: """ @@ -325,9 +329,9 @@ class DownloadOrchestrator: """ # Detect which client to use based on username source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, - 'hifi': self.hifi, 'deezer_dl': self.deezer_dl} + 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr} source_names = {'youtube': 'YouTube', 'tidal': 'Tidal', 'qobuz': 'Qobuz', - 'hifi': 'HiFi', 'deezer_dl': 'Deezer'} + 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'} if username in source_map: client = source_map[username] diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py new file mode 100644 index 00000000..78534c9a --- /dev/null +++ b/core/lidarr_download_client.py @@ -0,0 +1,534 @@ +""" +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 diff --git a/web_server.py b/web_server.py index 7165ee3d..fb818a1c 100644 --- a/web_server.py +++ b/web_server.py @@ -1955,7 +1955,7 @@ def _record_library_history_download(context): # Determine download source search_result = context.get('original_search_result') or context.get('search_result') or {} username = search_result.get('username', context.get('_download_username', '')) - _svc_map = {'youtube': 'YouTube', 'tidal': 'Tidal', 'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer'} + _svc_map = {'youtube': 'YouTube', 'tidal': 'Tidal', 'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'} download_source = _svc_map.get(username, 'Soulseek') ti = context.get('track_info') or context.get('search_result') or {} @@ -2002,7 +2002,7 @@ def _record_library_history_download(context): source_track_title = search_result.get('title', '') or search_result.get('name', '') source_artist = search_result.get('artist', '') # For streaming sources, track ID is encoded in filename as "id||display_name" - if source_filename and '||' in source_filename and username in ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl'): + if source_filename and '||' in source_filename and username in ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): _stream_id = source_filename.split('||')[0] if _stream_id and not source_track_id: source_track_id = _stream_id @@ -2039,7 +2039,7 @@ def _record_download_provenance(context): filename = search_result.get('filename', '') # Determine source service from username - service_map = {'youtube': 'youtube', 'tidal': 'tidal', 'qobuz': 'qobuz', 'hifi': 'hifi', 'deezer_dl': 'deezer'} + service_map = {'youtube': 'youtube', 'tidal': 'tidal', 'qobuz': 'qobuz', 'hifi': 'hifi', 'deezer_dl': 'deezer', 'lidarr': 'lidarr'} source_service = service_map.get(username, 'soulseek') # Track metadata @@ -2368,7 +2368,7 @@ def get_cached_transfer_data(): all_downloads = run_async(soulseek_client.get_all_downloads()) if not soulseek_known_down else [] for download in all_downloads: # Only add streaming source downloads (Soulseek ones are already in the lookup) - if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format live_transfers_lookup[key] = { @@ -2695,7 +2695,7 @@ class WebUIDownloadMonitor: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: # Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API) - if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format for monitor compatibility live_transfers[key] = { @@ -4101,6 +4101,21 @@ def run_service_test(service, test_config): return False, "Invalid Genius access token." except Exception as e: return False, f"Genius connection error: {str(e)}" + elif service == "lidarr" or service == "lidarr_download": + url = config_manager.get('lidarr_download.url', '') + api_key = config_manager.get('lidarr_download.api_key', '') + if not url or not api_key: + return False, "Lidarr URL and API key are required." + try: + import requests as _req + resp = _req.get(f"{url.rstrip('/')}/api/v1/system/status", + headers={'X-Api-Key': api_key}, timeout=10) + if resp.ok: + version = resp.json().get('version', '?') + return True, f"Connected to Lidarr v{version}" + return False, f"Lidarr returned HTTP {resp.status_code}" + except Exception as e: + return False, f"Lidarr connection error: {str(e)}" return False, "Unknown service." except AttributeError as e: # This specifically catches the error you reported for Jellyfin @@ -5184,7 +5199,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -8238,7 +8253,7 @@ def stream_enhanced_search_track(): search_queries = [] import re - if effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'): + if effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): # Streaming sources: Include artist for better context if artist_name and track_name: search_queries.append(f"{artist_name} {track_name}".strip()) @@ -8280,6 +8295,7 @@ def stream_enhanced_search_track(): 'qobuz': soulseek_client.qobuz, 'hifi': soulseek_client.hifi, 'deezer_dl': soulseek_client.deezer_dl, + 'lidarr': soulseek_client.lidarr, } stream_client = _stream_clients.get(effective_mode) use_direct_client = stream_client is not None @@ -8371,10 +8387,20 @@ def download_music_video(): if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading': return jsonify({"error": "Already downloading"}), 409 - # Get music videos path - music_videos_path = config_manager.get('library.music_videos_path', './MusicVideos') + # Get and validate music videos path + music_videos_path = config_manager.get('library.music_videos_path', '') or '' + if not music_videos_path.strip(): + return jsonify({"error": "Music Videos directory not configured. Set it in Settings > Downloads."}), 400 music_videos_path = docker_resolve_path(music_videos_path) - os.makedirs(music_videos_path, exist_ok=True) + try: + os.makedirs(music_videos_path, exist_ok=True) + # Quick write test + test_file = os.path.join(music_videos_path, '.soulsync_write_test') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + except (OSError, PermissionError) as e: + return jsonify({"error": f"Music Videos directory is not writable: {e}"}), 400 # Initialize download state _music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None} @@ -8558,7 +8584,7 @@ def start_download(): if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) - is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl') + is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { @@ -8936,7 +8962,7 @@ def get_download_status(): all_streaming_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_streaming_downloads: - if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects streaming_transfer = { @@ -28634,7 +28660,7 @@ def _store_batch_source(batch_id, username, filename): """Browse the successful download's folder and store results on the batch for reuse.""" _sr = source_reuse_logger _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") - if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'): + if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): _sr.info(f"Skipped — no batch_id or streaming source ({username})") return diff --git a/webui/index.html b/webui/index.html index d2783f65..c794273f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4613,6 +4613,7 @@ +