Merge branch 'Nezreka:main' into main
This commit is contained in:
commit
6f5c06906a
8 changed files with 735 additions and 31 deletions
|
|
@ -79,6 +79,7 @@ class ConfigManager:
|
||||||
# Download sources
|
# Download sources
|
||||||
'soulseek.api_key',
|
'soulseek.api_key',
|
||||||
'deezer_download.arl',
|
'deezer_download.arl',
|
||||||
|
'lidarr_download.api_key',
|
||||||
# Enrichment services
|
# Enrichment services
|
||||||
'listenbrainz.token',
|
'listenbrainz.token',
|
||||||
'acoustid.api_key',
|
'acoustid.api_key',
|
||||||
|
|
@ -409,6 +410,13 @@ class ConfigManager:
|
||||||
"hifi_download": {
|
"hifi_download": {
|
||||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||||
},
|
},
|
||||||
|
"lidarr_download": {
|
||||||
|
"url": "",
|
||||||
|
"api_key": "",
|
||||||
|
"root_folder": "",
|
||||||
|
"quality_profile": "Any",
|
||||||
|
"cleanup_after_import": True,
|
||||||
|
},
|
||||||
"listenbrainz": {
|
"listenbrainz": {
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"token": "",
|
"token": "",
|
||||||
|
|
@ -462,7 +470,7 @@ class ConfigManager:
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"music_paths": [],
|
"music_paths": [],
|
||||||
"music_videos_path": "./MusicVideos"
|
"music_videos_path": ""
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"path": "./scripts",
|
"path": "./scripts",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from core.tidal_download_client import TidalDownloadClient
|
||||||
from core.qobuz_client import QobuzClient
|
from core.qobuz_client import QobuzClient
|
||||||
from core.hifi_client import HiFiClient
|
from core.hifi_client import HiFiClient
|
||||||
from core.deezer_download_client import DeezerDownloadClient
|
from core.deezer_download_client import DeezerDownloadClient
|
||||||
|
from core.lidarr_download_client import LidarrDownloadClient
|
||||||
|
|
||||||
logger = get_logger("download_orchestrator")
|
logger = get_logger("download_orchestrator")
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ class DownloadOrchestrator:
|
||||||
self.qobuz = self._safe_init('Qobuz', QobuzClient)
|
self.qobuz = self._safe_init('Qobuz', QobuzClient)
|
||||||
self.hifi = self._safe_init('HiFi', HiFiClient)
|
self.hifi = self._safe_init('HiFi', HiFiClient)
|
||||||
self.deezer_dl = self._safe_init('Deezer', DeezerDownloadClient)
|
self.deezer_dl = self._safe_init('Deezer', DeezerDownloadClient)
|
||||||
|
self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient)
|
||||||
|
|
||||||
if self._init_failures:
|
if self._init_failures:
|
||||||
logger.warning(f"⚠️ Download clients failed to initialize: {', '.join(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):
|
def _client(self, name):
|
||||||
"""Get a client by name, returning None if not initialized."""
|
"""Get a client by name, returning None if not initialized."""
|
||||||
return {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal,
|
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:
|
def is_configured(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -117,7 +120,8 @@ class DownloadOrchestrator:
|
||||||
return {name: (c.is_configured() if c else False)
|
return {name: (c.is_configured() if c else False)
|
||||||
for name, c in [('soulseek', self.soulseek), ('youtube', self.youtube),
|
for name, c in [('soulseek', self.soulseek), ('youtube', self.youtube),
|
||||||
('tidal', self.tidal), ('qobuz', self.qobuz),
|
('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:
|
async def check_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -129,7 +133,7 @@ class DownloadOrchestrator:
|
||||||
if client and self.mode != 'hybrid':
|
if client and self.mode != 'hybrid':
|
||||||
return await client.check_connection()
|
return await client.check_connection()
|
||||||
elif self.mode == 'hybrid':
|
elif self.mode == 'hybrid':
|
||||||
sources_to_check = self.hybrid_order if self.hybrid_order else ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl']
|
sources_to_check = self.hybrid_order if self.hybrid_order else ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr']
|
||||||
results = {}
|
results = {}
|
||||||
for source in sources_to_check:
|
for source in sources_to_check:
|
||||||
client = self._client(source)
|
client = self._client(source)
|
||||||
|
|
@ -159,7 +163,7 @@ class DownloadOrchestrator:
|
||||||
Tuple of (track_results, album_results)
|
Tuple of (track_results, album_results)
|
||||||
"""
|
"""
|
||||||
source_names = {'soulseek': 'Soulseek', 'youtube': 'YouTube', 'tidal': 'Tidal',
|
source_names = {'soulseek': 'Soulseek', 'youtube': 'YouTube', 'tidal': 'Tidal',
|
||||||
'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer'}
|
'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'}
|
||||||
|
|
||||||
if self.mode != 'hybrid':
|
if self.mode != 'hybrid':
|
||||||
client = self._client(self.mode)
|
client = self._client(self.mode)
|
||||||
|
|
@ -243,7 +247,7 @@ class DownloadOrchestrator:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 2. Filter and validate results
|
# 2. Filter and validate results
|
||||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl')
|
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr')
|
||||||
is_streaming = tracks[0].username in _streaming_sources if tracks else False
|
is_streaming = tracks[0].username in _streaming_sources if tracks else False
|
||||||
|
|
||||||
if is_streaming and expected_track:
|
if is_streaming and expected_track:
|
||||||
|
|
@ -325,9 +329,9 @@ class DownloadOrchestrator:
|
||||||
"""
|
"""
|
||||||
# Detect which client to use based on username
|
# Detect which client to use based on username
|
||||||
source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz,
|
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',
|
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:
|
if username in source_map:
|
||||||
client = source_map[username]
|
client = source_map[username]
|
||||||
|
|
@ -395,7 +399,7 @@ class DownloadOrchestrator:
|
||||||
"""
|
"""
|
||||||
# If username is provided, route directly to that source
|
# If username is provided, route directly to that source
|
||||||
source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz,
|
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}
|
||||||
if username in source_map:
|
if username in source_map:
|
||||||
client = source_map[username]
|
client = source_map[username]
|
||||||
return await client.cancel_download(download_id, username, remove) if client else False
|
return await client.cancel_download(download_id, username, remove) if client else False
|
||||||
|
|
|
||||||
534
core/lidarr_download_client.py
Normal file
534
core/lidarr_download_client.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -353,6 +353,7 @@ class WatchlistScanner:
|
||||||
self._database = None
|
self._database = None
|
||||||
self._wishlist_service = None
|
self._wishlist_service = None
|
||||||
self._matching_engine = None
|
self._matching_engine = None
|
||||||
|
self._rescan_cutoff_log_marker = None
|
||||||
|
|
||||||
if metadata_service:
|
if metadata_service:
|
||||||
self._metadata_service = metadata_service
|
self._metadata_service = metadata_service
|
||||||
|
|
@ -932,7 +933,9 @@ class WatchlistScanner:
|
||||||
# Check if a lookback period change requires a one-time wider window
|
# Check if a lookback period change requires a one-time wider window
|
||||||
rescan_cutoff = self._get_rescan_cutoff()
|
rescan_cutoff = self._get_rescan_cutoff()
|
||||||
if rescan_cutoff == 'all':
|
if rescan_cutoff == 'all':
|
||||||
logger.info(f"Lookback period changed to 'all' — returning full discography")
|
if self._rescan_cutoff_log_marker != 'all':
|
||||||
|
logger.info(f"Lookback period changed to 'all' — returning full discography")
|
||||||
|
self._rescan_cutoff_log_marker = 'all'
|
||||||
cutoff_timestamp = None
|
cutoff_timestamp = None
|
||||||
needs_full_discog = True
|
needs_full_discog = True
|
||||||
elif rescan_cutoff is not None:
|
elif rescan_cutoff is not None:
|
||||||
|
|
@ -942,7 +945,10 @@ class WatchlistScanner:
|
||||||
if rescan_cutoff.tzinfo is None:
|
if rescan_cutoff.tzinfo is None:
|
||||||
rescan_cutoff = rescan_cutoff.replace(tzinfo=timezone.utc)
|
rescan_cutoff = rescan_cutoff.replace(tzinfo=timezone.utc)
|
||||||
if rescan_cutoff < scan_ts:
|
if rescan_cutoff < scan_ts:
|
||||||
logger.info(f"Lookback period change detected — expanding cutoff from {cutoff_timestamp} to {rescan_cutoff}")
|
marker = rescan_cutoff.isoformat()
|
||||||
|
if self._rescan_cutoff_log_marker != marker:
|
||||||
|
logger.info(f"Lookback period change detected — expanding cutoff from {cutoff_timestamp} to {rescan_cutoff}")
|
||||||
|
self._rescan_cutoff_log_marker = marker
|
||||||
cutoff_timestamp = rescan_cutoff
|
cutoff_timestamp = rescan_cutoff
|
||||||
else:
|
else:
|
||||||
# No scan timestamp — first scan, use lookback period
|
# No scan timestamp — first scan, use lookback period
|
||||||
|
|
@ -1248,6 +1254,7 @@ class WatchlistScanner:
|
||||||
cursor.execute("DELETE FROM metadata WHERE key = 'watchlist_rescan_cutoff'")
|
cursor.execute("DELETE FROM metadata WHERE key = 'watchlist_rescan_cutoff'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Cleared watchlist rescan cutoff flag")
|
logger.info("Cleared watchlist rescan cutoff flag")
|
||||||
|
self._rescan_cutoff_log_marker = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error clearing rescan cutoff: {e}")
|
logger.debug(f"Error clearing rescan cutoff: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("music_database")
|
logger = get_logger("music_database")
|
||||||
|
|
||||||
|
_database_initialized_paths = set()
|
||||||
|
_database_initialization_lock = threading.Lock()
|
||||||
|
|
||||||
# Import matching engine for enhanced similarity logic
|
# Import matching engine for enhanced similarity logic
|
||||||
try:
|
try:
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
|
@ -168,8 +171,19 @@ class MusicDatabase:
|
||||||
self.database_path = Path(database_path)
|
self.database_path = Path(database_path)
|
||||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database once per process for this path
|
||||||
self._initialize_database()
|
self._initialize_database_once()
|
||||||
|
|
||||||
|
def _initialize_database_once(self):
|
||||||
|
"""Run schema setup and migrations once per database path per process."""
|
||||||
|
db_key = str(self.database_path.resolve())
|
||||||
|
|
||||||
|
with _database_initialization_lock:
|
||||||
|
if db_key in _database_initialized_paths:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._initialize_database()
|
||||||
|
_database_initialized_paths.add(db_key)
|
||||||
|
|
||||||
def _get_connection(self) -> sqlite3.Connection:
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
"""Get a NEW database connection for each operation (thread-safe)"""
|
"""Get a NEW database connection for each operation (thread-safe)"""
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import logging.handlers as _logging_handlers
|
||||||
source_reuse_logger = _logging.getLogger("source_reuse")
|
source_reuse_logger = _logging.getLogger("source_reuse")
|
||||||
source_reuse_logger.setLevel(_logging.DEBUG)
|
source_reuse_logger.setLevel(_logging.DEBUG)
|
||||||
if not source_reuse_logger.handlers:
|
if not source_reuse_logger.handlers:
|
||||||
|
os.makedirs("logs", exist_ok=True)
|
||||||
_sr_handler = _logging_handlers.RotatingFileHandler(
|
_sr_handler = _logging_handlers.RotatingFileHandler(
|
||||||
"logs/source_reuse.log", encoding="utf-8", maxBytes=5*1024*1024, backupCount=2
|
"logs/source_reuse.log", encoding="utf-8", maxBytes=5*1024*1024, backupCount=2
|
||||||
)
|
)
|
||||||
|
|
@ -1955,7 +1956,7 @@ def _record_library_history_download(context):
|
||||||
# Determine download source
|
# Determine download source
|
||||||
search_result = context.get('original_search_result') or context.get('search_result') or {}
|
search_result = context.get('original_search_result') or context.get('search_result') or {}
|
||||||
username = search_result.get('username', context.get('_download_username', ''))
|
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')
|
download_source = _svc_map.get(username, 'Soulseek')
|
||||||
|
|
||||||
ti = context.get('track_info') or context.get('search_result') or {}
|
ti = context.get('track_info') or context.get('search_result') or {}
|
||||||
|
|
@ -2002,7 +2003,7 @@ def _record_library_history_download(context):
|
||||||
source_track_title = search_result.get('title', '') or search_result.get('name', '')
|
source_track_title = search_result.get('title', '') or search_result.get('name', '')
|
||||||
source_artist = search_result.get('artist', '')
|
source_artist = search_result.get('artist', '')
|
||||||
# For streaming sources, track ID is encoded in filename as "id||display_name"
|
# 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]
|
_stream_id = source_filename.split('||')[0]
|
||||||
if _stream_id and not source_track_id:
|
if _stream_id and not source_track_id:
|
||||||
source_track_id = _stream_id
|
source_track_id = _stream_id
|
||||||
|
|
@ -2039,7 +2040,7 @@ def _record_download_provenance(context):
|
||||||
filename = search_result.get('filename', '')
|
filename = search_result.get('filename', '')
|
||||||
|
|
||||||
# Determine source service from username
|
# 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')
|
source_service = service_map.get(username, 'soulseek')
|
||||||
|
|
||||||
# Track metadata
|
# Track metadata
|
||||||
|
|
@ -2368,7 +2369,7 @@ def get_cached_transfer_data():
|
||||||
all_downloads = run_async(soulseek_client.get_all_downloads()) if not soulseek_known_down else []
|
all_downloads = run_async(soulseek_client.get_all_downloads()) if not soulseek_known_down else []
|
||||||
for download in all_downloads:
|
for download in all_downloads:
|
||||||
# Only add streaming source downloads (Soulseek ones are already in the lookup)
|
# 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)
|
key = _make_context_key(download.username, download.filename)
|
||||||
# Convert DownloadStatus to transfer dict format
|
# Convert DownloadStatus to transfer dict format
|
||||||
live_transfers_lookup[key] = {
|
live_transfers_lookup[key] = {
|
||||||
|
|
@ -2695,7 +2696,7 @@ class WebUIDownloadMonitor:
|
||||||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||||
for download in all_downloads:
|
for download in all_downloads:
|
||||||
# Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API)
|
# 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)
|
key = _make_context_key(download.username, download.filename)
|
||||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||||
live_transfers[key] = {
|
live_transfers[key] = {
|
||||||
|
|
@ -4101,6 +4102,21 @@ def run_service_test(service, test_config):
|
||||||
return False, "Invalid Genius access token."
|
return False, "Invalid Genius access token."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Genius connection error: {str(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."
|
return False, "Unknown service."
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
# This specifically catches the error you reported for Jellyfin
|
# This specifically catches the error you reported for Jellyfin
|
||||||
|
|
@ -5184,7 +5200,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -8238,7 +8254,7 @@ def stream_enhanced_search_track():
|
||||||
search_queries = []
|
search_queries = []
|
||||||
import re
|
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
|
# Streaming sources: Include artist for better context
|
||||||
if artist_name and track_name:
|
if artist_name and track_name:
|
||||||
search_queries.append(f"{artist_name} {track_name}".strip())
|
search_queries.append(f"{artist_name} {track_name}".strip())
|
||||||
|
|
@ -8280,6 +8296,7 @@ def stream_enhanced_search_track():
|
||||||
'qobuz': soulseek_client.qobuz,
|
'qobuz': soulseek_client.qobuz,
|
||||||
'hifi': soulseek_client.hifi,
|
'hifi': soulseek_client.hifi,
|
||||||
'deezer_dl': soulseek_client.deezer_dl,
|
'deezer_dl': soulseek_client.deezer_dl,
|
||||||
|
'lidarr': soulseek_client.lidarr,
|
||||||
}
|
}
|
||||||
stream_client = _stream_clients.get(effective_mode)
|
stream_client = _stream_clients.get(effective_mode)
|
||||||
use_direct_client = stream_client is not None
|
use_direct_client = stream_client is not None
|
||||||
|
|
@ -8371,10 +8388,20 @@ def download_music_video():
|
||||||
if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading':
|
if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading':
|
||||||
return jsonify({"error": "Already downloading"}), 409
|
return jsonify({"error": "Already downloading"}), 409
|
||||||
|
|
||||||
# Get music videos path
|
# Get and validate music videos path
|
||||||
music_videos_path = config_manager.get('library.music_videos_path', './MusicVideos')
|
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)
|
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
|
# Initialize download state
|
||||||
_music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None}
|
_music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None}
|
||||||
|
|
@ -8558,7 +8585,7 @@ def start_download():
|
||||||
if download_id:
|
if download_id:
|
||||||
# Register download for post-processing (simple transfer to /Transfer)
|
# Register download for post-processing (simple transfer to /Transfer)
|
||||||
context_key = _make_context_key(username, filename)
|
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:
|
with matched_context_lock:
|
||||||
matched_downloads_context[context_key] = {
|
matched_downloads_context[context_key] = {
|
||||||
'search_result': {
|
'search_result': {
|
||||||
|
|
@ -8936,7 +8963,7 @@ def get_download_status():
|
||||||
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
||||||
|
|
||||||
for download in all_streaming_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()
|
source_label = download.username.title()
|
||||||
# Convert DownloadStatus to transfer format that frontend expects
|
# Convert DownloadStatus to transfer format that frontend expects
|
||||||
streaming_transfer = {
|
streaming_transfer = {
|
||||||
|
|
@ -14265,7 +14292,7 @@ def redownload_search_sources(track_id):
|
||||||
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
||||||
svc = source_name if source_name != 'default' else 'hybrid'
|
svc = source_name if source_name != 'default' else 'hybrid'
|
||||||
uname = candidate.username
|
uname = candidate.username
|
||||||
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
|
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'):
|
||||||
svc = uname
|
svc = uname
|
||||||
source_candidates.append({
|
source_candidates.append({
|
||||||
'username': uname,
|
'username': uname,
|
||||||
|
|
@ -21286,6 +21313,31 @@ def get_version_info():
|
||||||
"title": "What's New in SoulSync",
|
"title": "What's New in SoulSync",
|
||||||
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||||
"sections": [
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "🎬 Music Videos — Search & Download from YouTube",
|
||||||
|
"description": "New Music Videos tab in enhanced and global search for finding and downloading music videos",
|
||||||
|
"features": [
|
||||||
|
"• Music Videos pill tab alongside Spotify/Deezer/iTunes/Discogs in both search bars",
|
||||||
|
"• YouTube search returns video cards with 16:9 thumbnails, duration, channel name, view count",
|
||||||
|
"• Click any video to download — circular progress ring on thumbnail, green checkmark on completion",
|
||||||
|
"• Metadata matching — searches your primary source for clean artist/title before saving",
|
||||||
|
"• Saves to configurable Music Videos directory as Artist/Title-video.mp4 (Plex global folder format)",
|
||||||
|
"• Plex video type suffixes supported: -video, -lyrics, -live, -concert, -interview, -behindthescenes"
|
||||||
|
],
|
||||||
|
"usage_note": "Set your Music Videos directory in Settings > Downloads, then search for any artist in the search bar and click the Music Videos tab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "📦 Lidarr Download Source (Development)",
|
||||||
|
"description": "Use Lidarr as a download source for Usenet and torrent content",
|
||||||
|
"features": [
|
||||||
|
"• 7th download source alongside Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer",
|
||||||
|
"• SoulSync handles discovery and matching, Lidarr handles downloading via its indexers",
|
||||||
|
"• Configure with just URL + API key in Settings > Downloads",
|
||||||
|
"• Available as standalone source or in Hybrid mode priority order",
|
||||||
|
"• Currently in development — basic album search and download flow functional"
|
||||||
|
],
|
||||||
|
"usage_note": "Requires a running Lidarr instance with configured indexers and download clients. Set Download Source to 'Lidarr Only (Development)' or add to Hybrid order."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "🔧 Metadata Pipeline Overhaul — Fix Unknown Artist & Source Selection",
|
"title": "🔧 Metadata Pipeline Overhaul — Fix Unknown Artist & Source Selection",
|
||||||
"description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected",
|
"description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected",
|
||||||
|
|
@ -21363,6 +21415,11 @@ def get_version_info():
|
||||||
"title": "🔧 Fixes & Improvements",
|
"title": "🔧 Fixes & Improvements",
|
||||||
"description": "Bug fixes, quality of life improvements, and new settings",
|
"description": "Bug fixes, quality of life improvements, and new settings",
|
||||||
"features": [
|
"features": [
|
||||||
|
"• Dismissed maintenance findings no longer reappear on next scan — dedup check now includes dismissed status",
|
||||||
|
"• Orphan file detector: increased path matching depth to 4 segments + filename parsing fallback for unreadable tags",
|
||||||
|
"• Media player: rapid play clicks no longer create duplicate audio streams requiring browser refresh",
|
||||||
|
"• Logs directory auto-created on startup — prevents crash for non-Docker installations",
|
||||||
|
"• Stale discovery data re-processed in automation pipeline — tracks with missing metadata get re-enriched",
|
||||||
"• Artist names no longer stored as lowercase — fixed static method shadowing instance method. Run a database update to fix existing names.",
|
"• Artist names no longer stored as lowercase — fixed static method shadowing instance method. Run a database update to fix existing names.",
|
||||||
"• Watchlist scanner skips future/unreleased albums — no more garbage downloads from albums not yet out",
|
"• Watchlist scanner skips future/unreleased albums — no more garbage downloads from albums not yet out",
|
||||||
"• Playlist sync tracks now tagged with correct track numbers instead of always 01",
|
"• Playlist sync tracks now tagged with correct track numbers instead of always 01",
|
||||||
|
|
@ -28594,7 +28651,7 @@ def _try_source_reuse(task_id, batch_id, track):
|
||||||
if not source_tracks or not last_source:
|
if not source_tracks or not last_source:
|
||||||
_sr.info(f"Skipped — no source_tracks or no last_source")
|
_sr.info(f"Skipped — no source_tracks or no last_source")
|
||||||
return False
|
return False
|
||||||
if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
|
if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'):
|
||||||
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
|
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -28696,7 +28753,7 @@ def _store_batch_source(batch_id, username, filename):
|
||||||
"""Browse the successful download's folder and store results on the batch for reuse."""
|
"""Browse the successful download's folder and store results on the batch for reuse."""
|
||||||
_sr = source_reuse_logger
|
_sr = source_reuse_logger
|
||||||
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
|
_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})")
|
_sr.info(f"Skipped — no batch_id or streaming source ({username})")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4613,6 +4613,7 @@
|
||||||
<option value="qobuz">Qobuz Only</option>
|
<option value="qobuz">Qobuz Only</option>
|
||||||
<option value="hifi">HiFi Only (Free Lossless)</option>
|
<option value="hifi">HiFi Only (Free Lossless)</option>
|
||||||
<option value="deezer_dl">Deezer Only</option>
|
<option value="deezer_dl">Deezer Only</option>
|
||||||
|
<option value="lidarr">Lidarr Only (Development)</option>
|
||||||
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<div class="setting-help-text">
|
||||||
|
|
@ -4918,6 +4919,36 @@
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||||
<div id="youtube-settings-container" style="display: none;">
|
<div id="youtube-settings-container" style="display: none;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -3307,9 +3307,17 @@ function setLoadingProgress(percentage) {
|
||||||
// STREAMING FUNCTIONALITY
|
// STREAMING FUNCTIONALITY
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
|
let _streamLock = false;
|
||||||
|
|
||||||
async function startStream(searchResult) {
|
async function startStream(searchResult) {
|
||||||
// Start streaming a track - handles same track toggle and new track streaming
|
// Start streaming a track - handles same track toggle and new track streaming
|
||||||
try {
|
try {
|
||||||
|
// Prevent multiple concurrent stream starts (rapid clicking)
|
||||||
|
if (_streamLock) {
|
||||||
|
console.log('⏳ Stream already starting, ignoring duplicate click');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🎮 startStream() called with data:`, searchResult);
|
console.log(`🎮 startStream() called with data:`, searchResult);
|
||||||
|
|
||||||
// Check if this is the same track that's currently playing/loading
|
// Check if this is the same track that's currently playing/loading
|
||||||
|
|
@ -3327,6 +3335,9 @@ async function startStream(searchResult) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lock to prevent duplicate stream starts
|
||||||
|
_streamLock = true;
|
||||||
|
|
||||||
// Different track or no current track - start new stream
|
// Different track or no current track - start new stream
|
||||||
console.log("🎵 Starting new stream");
|
console.log("🎵 Starting new stream");
|
||||||
|
|
||||||
|
|
@ -3373,6 +3384,8 @@ async function startStream(searchResult) {
|
||||||
showToast(`Failed to start stream: ${error.message}`, 'error');
|
showToast(`Failed to start stream: ${error.message}`, 'error');
|
||||||
hideLoadingAnimation();
|
hideLoadingAnimation();
|
||||||
clearTrack();
|
clearTrack();
|
||||||
|
} finally {
|
||||||
|
_streamLock = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5596,10 +5609,11 @@ const HYBRID_SOURCES = [
|
||||||
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
||||||
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
|
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
|
||||||
{ id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' },
|
{ id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' },
|
||||||
|
{ id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let _hybridSourceOrder = ['soulseek', 'youtube'];
|
let _hybridSourceOrder = ['soulseek', 'youtube'];
|
||||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false };
|
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false };
|
||||||
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
||||||
|
|
||||||
function buildHybridSourceList() {
|
function buildHybridSourceList() {
|
||||||
|
|
@ -5915,6 +5929,8 @@ async function loadSettingsData() {
|
||||||
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
|
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-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
|
||||||
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
|
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
|
// Sync ARL to connections tab field + bidirectional listeners
|
||||||
const _connArl = document.getElementById('deezer-connection-arl');
|
const _connArl = document.getElementById('deezer-connection-arl');
|
||||||
const _dlArl = document.getElementById('deezer-download-arl');
|
const _dlArl = document.getElementById('deezer-download-arl');
|
||||||
|
|
@ -6211,6 +6227,7 @@ function updateDownloadSourceUI() {
|
||||||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||||
const hifiContainer = document.getElementById('hifi-download-settings-container');
|
const hifiContainer = document.getElementById('hifi-download-settings-container');
|
||||||
const deezerDlContainer = document.getElementById('deezer-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';
|
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||||
|
|
||||||
|
|
@ -6231,6 +6248,7 @@ function updateDownloadSourceUI() {
|
||||||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||||
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
|
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
|
||||||
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? '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
|
// Quality profile is Soulseek-only and downloads-tab-only
|
||||||
const qualityProfileSection = document.getElementById('quality-profile-section');
|
const qualityProfileSection = document.getElementById('quality-profile-section');
|
||||||
|
|
@ -7099,6 +7117,10 @@ async function saveSettings(quiet = false) {
|
||||||
arl: document.getElementById('deezer-download-arl').value || '',
|
arl: document.getElementById('deezer-download-arl').value || '',
|
||||||
allow_fallback: document.getElementById('deezer-allow-fallback').checked,
|
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: {
|
qobuz: {
|
||||||
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
||||||
embed_tags: document.getElementById('embed-qobuz').checked,
|
embed_tags: document.getElementById('embed-qobuz').checked,
|
||||||
|
|
@ -7823,6 +7845,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() {
|
async function checkHiFiInstances() {
|
||||||
const panel = document.getElementById('hifi-instances-panel');
|
const panel = document.getElementById('hifi-instances-panel');
|
||||||
const btn = document.getElementById('hifi-instances-check-btn');
|
const btn = document.getElementById('hifi-instances-check-btn');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue