Merge branch 'Nezreka:main' into main

This commit is contained in:
FelixClements 2026-04-11 20:19:52 +02:00 committed by GitHub
commit 6f5c06906a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 735 additions and 31 deletions

View file

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

View file

@ -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:
"""
@ -129,7 +133,7 @@ class DownloadOrchestrator:
if client and self.mode != 'hybrid':
return await client.check_connection()
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 = {}
for source in sources_to_check:
client = self._client(source)
@ -159,7 +163,7 @@ class DownloadOrchestrator:
Tuple of (track_results, album_results)
"""
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':
client = self._client(self.mode)
@ -243,7 +247,7 @@ class DownloadOrchestrator:
return None
# 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
if is_streaming and expected_track:
@ -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]
@ -395,7 +399,7 @@ class DownloadOrchestrator:
"""
# If username is provided, route directly to that source
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:
client = source_map[username]
return await client.cancel_download(download_id, username, remove) if client else False

View 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

View file

@ -353,6 +353,7 @@ class WatchlistScanner:
self._database = None
self._wishlist_service = None
self._matching_engine = None
self._rescan_cutoff_log_marker = None
if 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
rescan_cutoff = self._get_rescan_cutoff()
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
needs_full_discog = True
elif rescan_cutoff is not None:
@ -942,7 +945,10 @@ class WatchlistScanner:
if rescan_cutoff.tzinfo is None:
rescan_cutoff = rescan_cutoff.replace(tzinfo=timezone.utc)
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
else:
# No scan timestamp — first scan, use lookback period
@ -1248,6 +1254,7 @@ class WatchlistScanner:
cursor.execute("DELETE FROM metadata WHERE key = 'watchlist_rescan_cutoff'")
conn.commit()
logger.info("Cleared watchlist rescan cutoff flag")
self._rescan_cutoff_log_marker = None
except Exception as e:
logger.debug(f"Error clearing rescan cutoff: {e}")
@ -3266,4 +3273,4 @@ def get_watchlist_scanner(spotify_client: SpotifyClient) -> WatchlistScanner:
global _watchlist_scanner_instance
if _watchlist_scanner_instance is None:
_watchlist_scanner_instance = WatchlistScanner(spotify_client)
return _watchlist_scanner_instance
return _watchlist_scanner_instance

View file

@ -15,6 +15,9 @@ from utils.logging_config import get_logger
logger = get_logger("music_database")
_database_initialized_paths = set()
_database_initialization_lock = threading.Lock()
# Import matching engine for enhanced similarity logic
try:
from core.matching_engine import MusicMatchingEngine
@ -168,8 +171,19 @@ class MusicDatabase:
self.database_path = Path(database_path)
self.database_path.parent.mkdir(parents=True, exist_ok=True)
# Initialize database
self._initialize_database()
# Initialize database once per process for this path
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:
"""Get a NEW database connection for each operation (thread-safe)"""
@ -10796,4 +10810,4 @@ def close_database():
except Exception as e:
# Ignore threading errors during shutdown
pass
_database_instances.clear()
_database_instances.clear()

View file

@ -40,6 +40,7 @@ import logging.handlers as _logging_handlers
source_reuse_logger = _logging.getLogger("source_reuse")
source_reuse_logger.setLevel(_logging.DEBUG)
if not source_reuse_logger.handlers:
os.makedirs("logs", exist_ok=True)
_sr_handler = _logging_handlers.RotatingFileHandler(
"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
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 +2003,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 +2040,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 +2369,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 +2696,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 +4102,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 +5200,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 +8254,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 +8296,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 +8388,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 +8585,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 +8963,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 = {
@ -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 ''
svc = source_name if source_name != 'default' else 'hybrid'
uname = candidate.username
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'):
svc = uname
source_candidates.append({
'username': uname,
@ -21286,6 +21313,31 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"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",
"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",
"description": "Bug fixes, quality of life improvements, and new settings",
"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.",
"• 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",
@ -28594,7 +28651,7 @@ def _try_source_reuse(task_id, batch_id, track):
if not source_tracks or not last_source:
_sr.info(f"Skipped — no source_tracks or no last_source")
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)")
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."""
_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

View file

@ -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 (Development)</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">

View file

@ -3307,9 +3307,17 @@ function setLoadingProgress(percentage) {
// STREAMING FUNCTIONALITY
// ===============================
let _streamLock = false;
async function startStream(searchResult) {
// Start streaming a track - handles same track toggle and new track streaming
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);
// Check if this is the same track that's currently playing/loading
@ -3327,6 +3335,9 @@ async function startStream(searchResult) {
return;
}
// Lock to prevent duplicate stream starts
_streamLock = true;
// Different track or no current track - start new stream
console.log("🎵 Starting new stream");
@ -3373,6 +3384,8 @@ async function startStream(searchResult) {
showToast(`Failed to start stream: ${error.message}`, 'error');
hideLoadingAnimation();
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: 'hifi', name: 'HiFi', icon: null, 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 _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
function buildHybridSourceList() {
@ -5915,6 +5929,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 +6227,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 +6248,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 +7117,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 +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() {
const panel = document.getElementById('hifi-instances-panel');
const btn = document.getElementById('hifi-instances-check-btn');