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)
pull/279/head
Broque Thomas 1 month ago
parent 1dcdccb282
commit fc38ec4787

@ -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",

@ -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]

@ -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

@ -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

@ -4613,6 +4613,7 @@
<option value="qobuz">Qobuz Only</option>
<option value="hifi">HiFi Only (Free Lossless)</option>
<option value="deezer_dl">Deezer Only</option>
<option value="lidarr">Lidarr Only (Usenet/Torrent)</option>
<option value="hybrid">Hybrid (Primary + Fallback)</option>
</select>
<div class="setting-help-text">
@ -4918,6 +4919,36 @@
</div>
</div>
<!-- Lidarr Download Settings -->
<div id="lidarr-download-settings-container" style="display: none;">
<div class="form-group">
<label>Lidarr URL:</label>
<input type="text" id="lidarr-url" placeholder="http://localhost:8686">
<div class="setting-help-text">
Full URL to your Lidarr instance (e.g. http://192.168.1.100:8686)
</div>
</div>
<div class="form-group">
<label>API Key:</label>
<input type="password" id="lidarr-api-key" placeholder="Your Lidarr API key">
<div class="setting-help-text">
Found in Lidarr → Settings → General → Security → API Key
</div>
</div>
<div class="form-group">
<label>Lidarr Status:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="test-button" id="lidarr-test-btn" onclick="testLidarrConnection()">
Test Connection
</button>
<span id="lidarr-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
<div class="setting-help-text" style="margin-top: 6px;">
Uses Lidarr's indexers and download clients (Usenet/torrent) for album downloads.
</div>
</div>
</div>
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
<div id="youtube-settings-container" style="display: none;">
<div class="form-group">

@ -5915,6 +5915,8 @@ async function loadSettingsData() {
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
document.getElementById('lidarr-url').value = settings.lidarr_download?.url || '';
document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || '';
// Sync ARL to connections tab field + bidirectional listeners
const _connArl = document.getElementById('deezer-connection-arl');
const _dlArl = document.getElementById('deezer-download-arl');
@ -6211,6 +6213,7 @@ function updateDownloadSourceUI() {
const youtubeContainer = document.getElementById('youtube-settings-container');
const hifiContainer = document.getElementById('hifi-download-settings-container');
const deezerDlContainer = document.getElementById('deezer-download-settings-container');
const lidarrContainer = document.getElementById('lidarr-download-settings-container');
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
@ -6231,6 +6234,7 @@ function updateDownloadSourceUI() {
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
// Quality profile is Soulseek-only and downloads-tab-only
const qualityProfileSection = document.getElementById('quality-profile-section');
@ -7099,6 +7103,10 @@ async function saveSettings(quiet = false) {
arl: document.getElementById('deezer-download-arl').value || '',
allow_fallback: document.getElementById('deezer-allow-fallback').checked,
},
lidarr_download: {
url: document.getElementById('lidarr-url').value || '',
api_key: document.getElementById('lidarr-api-key').value || '',
},
qobuz: {
quality: document.getElementById('qobuz-quality').value || 'lossless',
embed_tags: document.getElementById('embed-qobuz').checked,
@ -7823,6 +7831,33 @@ async function testHiFiConnection() {
}
}
async function testLidarrConnection() {
const statusEl = document.getElementById('lidarr-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
// Save settings first so the backend has the URL/key
await saveSettings();
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'lidarr' })
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = 'Connected';
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Connection failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function checkHiFiInstances() {
const panel = document.getElementById('hifi-instances-panel');
const btn = document.getElementById('hifi-instances-check-btn');

Loading…
Cancel
Save