Merge branch 'Nezreka:main' into main

This commit is contained in:
FelixClements 2026-04-11 16:05:47 +02:00 committed by GitHub
commit 54df01f137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1878 additions and 191 deletions

View file

@ -35,7 +35,7 @@ COPY . .
# Create necessary directories with proper permissions # Create necessary directories with proper permissions
# NOTE: /app/data is for database FILES, /app/database is the Python package # NOTE: /app/data is for database FILES, /app/database is the Python package
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/scripts && \ RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/MusicVideos /app/scripts && \
chown -R soulsync:soulsync /app chown -R soulsync:soulsync /app
# Create defaults directory and copy template files # Create defaults directory and copy template files
@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \
# Create volume mount points # Create volume mount points
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package # NOTE: Changed /app/database to /app/data to avoid overwriting Python package
VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/scripts"] VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/MusicVideos", "/app/scripts"]
# Copy and set up entrypoint script # Copy and set up entrypoint script
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View file

@ -42,16 +42,17 @@ def register_routes(bp):
pass pass
spotify = ctx.get("spotify_client") spotify = ctx.get("spotify_client")
if source in ("spotify", "auto") and spotify and spotify.is_authenticated(): from core.metadata_service import get_primary_source, get_primary_client
primary = get_primary_source()
if source in ("spotify", "auto") and primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
results = spotify.search_tracks(query, limit=limit) results = spotify.search_tracks(query, limit=limit)
if results: if results:
tracks = [_serialize_track(t) for t in results] tracks = [_serialize_track(t) for t in results]
return api_success({"tracks": tracks, "source": "spotify"}) return api_success({"tracks": tracks, "source": "spotify"})
if source in ("itunes", "deezer", "auto"): if source in ("itunes", "deezer", "auto"):
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source fallback = get_primary_client()
fallback = _create_fallback_client() fallback_source = get_primary_source()
fallback_source = _get_configured_fallback_source()
results = fallback.search_tracks(query, limit=limit) results = fallback.search_tracks(query, limit=limit)
if results: if results:
tracks = [_serialize_track(t) for t in results] tracks = [_serialize_track(t) for t in results]
@ -78,7 +79,9 @@ def register_routes(bp):
try: try:
ctx = current_app.soulsync ctx = current_app.soulsync
spotify = ctx.get("spotify_client") spotify = ctx.get("spotify_client")
if spotify and spotify.is_authenticated(): from core.metadata_service import get_primary_source, get_primary_client
primary = get_primary_source()
if primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
results = spotify.search_albums(query, limit=limit) results = spotify.search_albums(query, limit=limit)
if results: if results:
return api_success({ return api_success({
@ -86,9 +89,8 @@ def register_routes(bp):
"source": "spotify", "source": "spotify",
}) })
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source fallback = get_primary_client()
fallback = _create_fallback_client() fallback_source = get_primary_source()
fallback_source = _get_configured_fallback_source()
results = fallback.search_albums(query, limit=limit) results = fallback.search_albums(query, limit=limit)
return api_success({ return api_success({
"albums": [_serialize_album(a) for a in results] if results else [], "albums": [_serialize_album(a) for a in results] if results else [],
@ -114,7 +116,9 @@ def register_routes(bp):
try: try:
ctx = current_app.soulsync ctx = current_app.soulsync
spotify = ctx.get("spotify_client") spotify = ctx.get("spotify_client")
if spotify and spotify.is_authenticated(): from core.metadata_service import get_primary_source, get_primary_client
primary = get_primary_source()
if primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
results = spotify.search_artists(query, limit=limit) results = spotify.search_artists(query, limit=limit)
if results: if results:
return api_success({ return api_success({
@ -122,9 +126,8 @@ def register_routes(bp):
"source": "spotify", "source": "spotify",
}) })
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source fallback = get_primary_client()
fallback = _create_fallback_client() fallback_source = get_primary_source()
fallback_source = _get_configured_fallback_source()
results = fallback.search_artists(query, limit=limit) results = fallback.search_artists(query, limit=limit)
return api_success({ return api_success({
"artists": [_serialize_artist(a) for a in results] if results else [], "artists": [_serialize_artist(a) for a in results] if results else [],

View file

@ -461,7 +461,8 @@ class ConfigManager:
"poll_interval": 30 "poll_interval": 30
}, },
"library": { "library": {
"music_paths": [] "music_paths": [],
"music_videos_path": "./MusicVideos"
}, },
"scripts": { "scripts": {
"path": "./scripts", "path": "./scripts",

View file

@ -392,9 +392,11 @@ class DeezerClient:
cache = get_metadata_cache() cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'track', str(track_id)) cached = cache.get_entity('deezer', 'track', str(track_id))
if cached and cached.get('title'): if cached and cached.get('title'):
# Search results are cached with minimal data (no release_date, track_position). # Search results are cached with minimal data (no track_position).
# Only use cache if it has fields that the /track/{id} endpoint provides. # Only use cache if it has track_position — the key field from /track/{id}.
if 'release_date' in cached or 'track_position' in cached or 'isrc' in cached: # Search results include 'isrc' and 'release_date' but NOT track_position,
# so those fields alone are not sufficient to distinguish full from partial data.
if 'track_position' in cached:
return self._build_enhanced_track(cached) return self._build_enhanced_track(cached)
# Otherwise fall through to fetch full data from API # Otherwise fall through to fetch full data from API
@ -634,7 +636,17 @@ class DeezerClient:
albums.append(album) albums.append(album)
cache = get_metadata_cache() cache = get_metadata_cache()
entries = [(str(ad.get('id', '')), ad) for ad in data['data'] if ad.get('id')] # Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album.
# Inject it so cached album entities have artist_name for discover page display.
artist_stub = None
if albums and albums[0].artists:
artist_stub = {'id': int(artist_id) if artist_id.isdigit() else 0, 'name': albums[0].artists[0]}
entries = []
for ad in data['data']:
if ad.get('id'):
if artist_stub and not ad.get('artist'):
ad['artist'] = artist_stub
entries.append((str(ad['id']), ad))
if entries: if entries:
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True) cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)

View file

@ -602,23 +602,42 @@ class MusicMatchingEngine:
# No word boundary match - rely on similarity ratio only # No word boundary match - rely on similarity ratio only
title_score = title_ratio title_score = title_ratio
# 2. Artist Score: Keep substring matching for artists (they're more unique) # 2. Artist Score: Word-boundary matching for artists to prevent false positives
# But add similarity-based fallback for better matching # like "muse" matching "museum" or "art" matching "heart".
# Falls back to similarity matching for misspellings/variations.
artist_score = 0.0 artist_score = 0.0
best_artist_similarity = 0.0 best_artist_similarity = 0.0
# Split original filename into segments for per-segment matching.
# Handles path separators (/, \) and YouTube's || delimiter.
_artist_segments = re.split(r'[/\\|]+', slskd_track.filename)
_artist_segments_norm = [self.normalize_string(s) for s in _artist_segments if s.strip()]
for artist in spotify_artists_norm: for artist in spotify_artists_norm:
# Skip containment for very short names (≤2 chars) — "b" matches everything if not artist:
if artist and len(artist) > 2 and artist in slskd_filename_norm: continue
artist_score = 1.0 # Perfect match if any artist is found # Word boundary match against each segment — "muse" matches "muse" but not "museum"
found_boundary = False
for seg_norm in _artist_segments_norm:
if re.search(r'\b' + re.escape(artist) + r'\b', seg_norm):
found_boundary = True
break break
elif artist and len(artist) <= 2 and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm): # Also check full normalized string (handles flat filenames without separators)
if not found_boundary and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm):
found_boundary = True
if found_boundary:
artist_score = 1.0 artist_score = 1.0
break break
else: else:
# Try similarity matching as fallback for misspellings/variations # Try similarity matching per path segment for misspellings/variations.
artist_ratio = SequenceMatcher(None, artist, slskd_filename_norm).ratio() # Comparing against the full filename dilutes the score because the artist
best_artist_similarity = max(best_artist_similarity, artist_ratio) # name is a small fraction of "artist/album/track.flac".
for seg_norm in _artist_segments_norm:
if not seg_norm:
continue
seg_ratio = SequenceMatcher(None, artist, seg_norm).ratio()
best_artist_similarity = max(best_artist_similarity, seg_ratio)
# If no exact artist match, use best similarity with penalty # If no exact artist match, use best similarity with penalty
if artist_score == 0.0 and best_artist_similarity > 0: if artist_score == 0.0 and best_artist_similarity > 0:
@ -672,13 +691,35 @@ class MusicMatchingEngine:
) )
return 0.0 return 0.0
# --- Minimum Artist Gate ---
# Reject matches where the artist has no resemblance to the target.
# Without this, a perfect title match + good duration can push a completely
# wrong artist past the confidence threshold (e.g. "Hexagons" by lizzylou06
# when searching for "Hexagons" by Muse, or "Subhuman Nature" by Belvedere
# when searching for "Subhuman" by Periphery).
if not is_youtube and artist_score < 0.25:
logger.debug(
f"Artist gate reject: '{spotify_track.name}' by {spotify_track.artists} "
f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.25)"
)
return 0.0
# Softer artist gate for YouTube — artist extraction from video titles is
# unreliable, but completely wrong uploaders should still be caught.
if is_youtube and artist_score < 0.15:
logger.debug(
f"YouTube artist gate reject: '{spotify_track.name}' by {spotify_track.artists} "
f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.15)"
)
return 0.0
# --- Final Weighted Score --- # --- Final Weighted Score ---
if is_youtube: if is_youtube:
# For YouTube, rely more on Title and Duration since Artist is often missing from video titles # For YouTube, artist gets more weight than before to reduce wrong-uploader matches.
# and the search query already filtered by artist to some extent. # Previous: Title 70%, Artist 10%, Duration 20% — artist was nearly irrelevant.
# New weights: Title 70%, Artist 10%, Duration 20% # New: Title 60%, Artist 20%, Duration 20%
final_confidence = (title_score * 0.70) + (artist_score * 0.10) + (duration_score * 0.20) final_confidence = (title_score * 0.60) + (artist_score * 0.20) + (duration_score * 0.20)
else: else:
# Standard weights for Soulseek (Artist is critical for correctness) # Standard weights for Soulseek (Artist is critical for correctness)
# Rebalanced weights: Artist matching is now more important to prevent false positives # Rebalanced weights: Artist matching is now more important to prevent false positives

View file

@ -1,9 +1,10 @@
""" """
Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider Metadata Service - Centralized metadata source selection
Automatically uses Spotify when authenticated, falls back to the configured ALL metadata source decisions flow through this module. Other files import
fallback source (iTunes or Deezer) when not. get_primary_source() and get_primary_client() instead of reimplementing
Provides unified interface for all metadata operations. the logic. This prevents bugs where different files have different defaults
or auth checks.
""" """
from typing import List, Optional, Dict, Any, Literal from typing import List, Optional, Dict, Any, Literal
@ -16,37 +17,106 @@ logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"] MetadataProvider = Literal["spotify", "itunes", "auto"]
def _get_configured_fallback_source(): # =============================================================================
"""Get the configured metadata fallback source ('itunes' or 'deezer').""" # CANONICAL SOURCE SELECTION — all code should use these two functions
# =============================================================================
def get_primary_source() -> str:
"""Get the user's configured primary metadata source.
Returns 'spotify', 'deezer', 'itunes', 'discogs', or 'hydrabase'.
If the user selected Spotify but it's not authenticated, falls back to 'deezer'.
This is THE single source of truth for "which metadata source should I use?"
All other modules should import this function instead of reading config directly.
"""
try: try:
from config.settings import config_manager from config.settings import config_manager
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
except Exception: except Exception:
return 'itunes' return 'deezer'
# Validate Spotify selection — can't use it if not authenticated
if source == 'spotify':
try:
import importlib
ws = importlib.import_module('web_server')
sc = getattr(ws, 'spotify_client', None)
if not sc or not sc.is_spotify_authenticated():
return 'deezer'
except Exception:
return 'deezer'
return source
def _create_fallback_client(): def get_primary_client():
"""Create the configured fallback metadata client.""" """Get the client object for the user's configured primary metadata source.
source = _get_configured_fallback_source()
Returns a SpotifyClient, DeezerClient, iTunesClient, DiscogsClient,
or HydrabaseClient instance.
This is THE single source of truth for "which client should I call?"
"""
source = get_primary_source()
if source == 'spotify':
try:
import importlib
ws = importlib.import_module('web_server')
sc = getattr(ws, 'spotify_client', None)
if sc and sc.is_spotify_authenticated():
return sc
except Exception:
pass
# Spotify selected but unavailable — fall back to Deezer
from core.deezer_client import DeezerClient
return DeezerClient()
if source == 'deezer': if source == 'deezer':
from core.deezer_client import DeezerClient from core.deezer_client import DeezerClient
return DeezerClient() return DeezerClient()
if source == 'discogs':
try:
from config.settings import config_manager
token = config_manager.get('discogs.token', '')
if token:
from core.discogs_client import DiscogsClient
return DiscogsClient(token=token)
except Exception:
pass
return iTunesClient()
if source == 'hydrabase': if source == 'hydrabase':
try: try:
from core.hydrabase_client import HydrabaseClient
# Hydrabase client is managed globally — try to import the running instance
import importlib import importlib
ws_module = importlib.import_module('web_server') ws = importlib.import_module('web_server')
client = getattr(ws_module, 'hydrabase_client', None) client = getattr(ws, 'hydrabase_client', None)
if client and client.is_connected(): if client and client.is_connected():
return client return client
except Exception: except Exception:
pass pass
# Hydrabase not available — fall back to iTunes
return iTunesClient() return iTunesClient()
# Default: iTunes
return iTunesClient() return iTunesClient()
# =============================================================================
# LEGACY ALIASES — kept for backward compatibility, delegate to canonical funcs
# =============================================================================
def _get_configured_fallback_source():
"""Legacy alias for get_primary_source(). Use get_primary_source() instead."""
return get_primary_source()
def _create_fallback_client():
"""Legacy alias for get_primary_client(). Use get_primary_client() instead."""
return get_primary_client()
class MetadataService: class MetadataService:
""" """
Unified metadata service that seamlessly switches between Spotify and Unified metadata service that seamlessly switches between Spotify and
@ -70,8 +140,8 @@ class MetadataService:
""" """
self.preferred_provider = preferred_provider self.preferred_provider = preferred_provider
self.spotify = SpotifyClient() self.spotify = SpotifyClient()
self._fallback_source = _get_configured_fallback_source() self._fallback_source = get_primary_source()
self.itunes = _create_fallback_client() # May be iTunesClient or DeezerClient self.itunes = get_primary_client() # May be iTunesClient or DeezerClient
self._log_initialization() self._log_initialization()
@ -94,10 +164,8 @@ class MetadataService:
return "spotify" return "spotify"
elif self.preferred_provider == "itunes": elif self.preferred_provider == "itunes":
return self._fallback_source return self._fallback_source
else: # auto else: # auto — use the centralized source selection
# Use is_spotify_authenticated() to check actual Spotify auth status return get_primary_source()
# (is_authenticated() always returns True due to fallback)
return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source
def _get_client(self): def _get_client(self):
"""Get the appropriate client based on provider selection""" """Get the appropriate client based on provider selection"""
@ -244,10 +312,10 @@ class MetadataService:
logger.info("Reloading metadata service configuration") logger.info("Reloading metadata service configuration")
self.spotify.reload_config() self.spotify.reload_config()
# Re-create fallback client in case the setting changed # Re-create fallback client in case the setting changed
new_source = _get_configured_fallback_source() new_source = get_primary_source()
if new_source != self._fallback_source: if new_source != self._fallback_source:
self._fallback_source = new_source self._fallback_source = new_source
self.itunes = _create_fallback_client() self.itunes = get_primary_client()
elif hasattr(self.itunes, 'reload_config'): elif hasattr(self.itunes, 'reload_config'):
self.itunes.reload_config() self.itunes.reload_config()
self._log_initialization() self._log_initialization()

View file

@ -101,18 +101,9 @@ class PersonalizedPlaylistsService:
self.spotify_client = spotify_client self.spotify_client = spotify_client
def _get_active_source(self) -> str: def _get_active_source(self) -> str:
""" """Determine which music source is active — delegates to centralized metadata_service."""
Determine which music source is active for discovery. from core.metadata_service import get_primary_source
Returns 'spotify' if Spotify is authenticated, otherwise the configured fallback ('itunes' or 'deezer'). return get_primary_source()
"""
if self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated'):
if self.spotify_client.is_spotify_authenticated():
return 'spotify'
try:
from config.settings import config_manager
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
except Exception:
return 'itunes'
def _build_track_dict(self, row, source: str) -> Dict: def _build_track_dict(self, row, source: str) -> Dict:
"""Build a standardized track dictionary from a database row.""" """Build a standardized track dictionary from a database row."""
@ -882,8 +873,8 @@ class PersonalizedPlaylistsService:
logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}") logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}")
return {'tracks': [], 'error': 'Must provide 1-5 seed artists'} return {'tracks': [], 'error': 'Must provide 1-5 seed artists'}
use_spotify = self.spotify_client and self.spotify_client.sp active_source = self._get_active_source()
active_source = 'spotify' if use_spotify else self._get_active_source() use_spotify = (active_source == 'spotify') and self.spotify_client and self.spotify_client.sp
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {active_source})") logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {active_source})")
# Step 1: Get similar artists for each seed # Step 1: Get similar artists for each seed
@ -914,8 +905,8 @@ class PersonalizedPlaylistsService:
seen_artist_ids.add(artist_id) seen_artist_ids.add(artist_id)
if len(all_similar_artists) >= 25: if len(all_similar_artists) >= 25:
break break
elif use_spotify: elif self.spotify_client and self.spotify_client.sp:
# Fallback: fetch related artists from Spotify API # Fallback: fetch related artists from Spotify API (no Deezer/iTunes equivalent)
logger.info(f"No cached similar artists for {seed_artist_id}, trying Spotify related artists API") logger.info(f"No cached similar artists for {seed_artist_id}, trying Spotify related artists API")
try: try:
related = self.spotify_client.sp.artist_related_artists(seed_artist_id) related = self.spotify_client.sp.artist_related_artists(seed_artist_id)
@ -963,8 +954,8 @@ class PersonalizedPlaylistsService:
logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}") logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}")
continue continue
else: else:
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
itunes = _create_fallback_client() itunes = get_primary_client()
for artist in artists_for_albums: for artist in artists_for_albums:
try: try:
albums = itunes.get_artist_albums(artist['id'], limit=10) albums = itunes.get_artist_albums(artist['id'], limit=10)
@ -1019,8 +1010,8 @@ class PersonalizedPlaylistsService:
logger.warning(f"Error getting tracks from album: {e}") logger.warning(f"Error getting tracks from album: {e}")
continue continue
else: else:
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
itunes = _create_fallback_client() itunes = get_primary_client()
for album in selected_albums: for album in selected_albums:
try: try:
album_data = itunes.get_album(album.id, include_tracks=True) album_data = itunes.get_album(album.id, include_tracks=True)

View file

@ -42,6 +42,7 @@ _JOB_MODULES = [
'core.repair_jobs.lossy_converter', 'core.repair_jobs.lossy_converter',
'core.repair_jobs.album_tag_consistency', 'core.repair_jobs.album_tag_consistency',
'core.repair_jobs.live_commentary_cleaner', 'core.repair_jobs.live_commentary_cleaner',
'core.repair_jobs.unknown_artist_fixer',
] ]

View file

@ -785,8 +785,8 @@ class LibraryReorganizeJob(RepairJob):
if not search_client: if not search_client:
# Try fallback (iTunes/Deezer) # Try fallback (iTunes/Deezer)
try: try:
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
search_client = _create_fallback_client() search_client = get_primary_client()
source_name = 'fallback' source_name = 'fallback'
except Exception: except Exception:
pass pass

View file

@ -61,8 +61,9 @@ class OrphanFileDetectorJob(RepairJob):
cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''")
for row in cursor.fetchall(): for row in cursor.fetchall():
parts = row[0].replace('\\', '/').split('/') parts = row[0].replace('\\', '/').split('/')
# Store last 1, 2, and 3 path components as lowercase suffixes # Store last 1-4 path components as lowercase suffixes.
for depth in range(1, min(4, len(parts) + 1)): # Depth 4 covers Genre/Artist/Album/track.flac scenarios.
for depth in range(1, min(5, len(parts) + 1)):
suffix = '/'.join(parts[-depth:]).lower() suffix = '/'.join(parts[-depth:]).lower()
known_suffixes.add(suffix) known_suffixes.add(suffix)
@ -127,7 +128,7 @@ class OrphanFileDetectorJob(RepairJob):
# Check if this file matches any known DB path via suffix matching # Check if this file matches any known DB path via suffix matching
fpath_parts = fpath.replace('\\', '/').split('/') fpath_parts = fpath.replace('\\', '/').split('/')
is_known = False is_known = False
for depth in range(1, min(4, len(fpath_parts) + 1)): for depth in range(1, min(5, len(fpath_parts) + 1)):
suffix = '/'.join(fpath_parts[-depth:]).lower() suffix = '/'.join(fpath_parts[-depth:]).lower()
if suffix in known_suffixes: if suffix in known_suffixes:
is_known = True is_known = True
@ -161,6 +162,33 @@ class OrphanFileDetectorJob(RepairJob):
except Exception: except Exception:
pass pass
# Last resort: parse title from filename pattern "NN - Title [Quality].ext"
# and match against known titles. Catches files with unreadable tags.
if not is_known and known_titles:
try:
fname_base = os.path.splitext(os.path.basename(fpath))[0]
# Strip quality tags like [FLAC 16bit], [MP3-320]
fname_clean = re.sub(r'\s*\[.*?\]\s*$', '', fname_base).strip()
# Strip leading track number: "01 - Title" → "Title"
fname_clean = re.sub(r'^\d{1,3}\s*[-.]\s*', '', fname_clean).strip()
if fname_clean:
fname_lower = fname_clean.lower()
# Extract artist from parent folder
parent_folder = os.path.basename(os.path.dirname(fpath)).lower().strip()
# Try artist from grandparent (Artist/Album/track.flac)
grandparent = os.path.basename(os.path.dirname(os.path.dirname(fpath))).lower().strip()
for folder_artist in [parent_folder, grandparent]:
if (fname_lower, folder_artist) in known_titles:
is_known = True
break
clean_fn = _strip_extras(fname_lower)
clean_fa = _strip_extras(folder_artist)
if clean_fn and (clean_fn, clean_fa) in known_titles_clean:
is_known = True
break
except Exception:
pass
if not is_known: if not is_known:
orphan_files.append(fpath) orphan_files.append(fpath)

View file

@ -0,0 +1,496 @@
"""Unknown Artist Fixer Job — finds tracks tagged as 'Unknown Artist' and corrects metadata.
Resolves the correct artist/album/track metadata from file tags or metadata API,
re-tags the audio file, moves it to the correct folder, and updates the database.
"""
import os
import re
import shutil
import sys
import time
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
logger = get_logger("repair_job.unknown_artist_fixer")
_UNKNOWN_NAMES = {'unknown artist', 'unknown', ''}
# Sidecar extensions to move alongside audio files
_SIDECAR_EXTS = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'}
@register_job
class UnknownArtistFixerJob(RepairJob):
job_id = 'unknown_artist_fixer'
display_name = 'Fix Unknown Artists'
description = 'Finds tracks tagged as "Unknown Artist" and corrects metadata, tags, and file paths'
help_text = (
'Scans your library for tracks filed under "Unknown Artist" — a common result of '
'incomplete metadata during playlist pipeline downloads.\n\n'
'For each affected track, the job resolves the correct artist, album, and track number by:\n'
'1. Reading embedded file tags (if the file itself has correct metadata)\n'
'2. Looking up the track by ID on your configured metadata source\n'
'3. Searching by track title as a last resort\n\n'
'When a match is found, the job can re-tag the file, move it to the correct folder, '
'and update the database.\n\n'
'Settings:\n'
'- Dry Run: Preview changes without applying them (default: on)\n'
'- Fix file tags: Write corrected metadata to audio file tags\n'
'- Reorganize files: Move files to the correct folder structure'
)
icon = 'repair-icon-artist'
default_enabled = False
default_interval_hours = 168 # Weekly
default_settings = {
'dry_run': True,
'fix_tags': True,
'reorganize_files': True,
}
auto_fix = True
def estimate_scope(self, context: JobContext) -> int:
try:
conn = context.db._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*) FROM tracks t
JOIN artists ar ON ar.id = t.artist_id
WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '')
AND t.file_path IS NOT NULL AND t.file_path != ''
""")
return cursor.fetchone()[0]
finally:
conn.close()
except Exception:
return 0
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
dry_run = settings.get('dry_run', True)
fix_tags = settings.get('fix_tags', True)
reorganize_files = settings.get('reorganize_files', True)
mode_label = 'DRY RUN' if dry_run else 'LIVE'
if context.report_progress:
context.report_progress(phase=f'Scanning ({mode_label})...',
log_line=f'Mode: {mode_label}', log_type='info')
# Query all tracks under Unknown Artist
conn = context.db._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT t.id, t.title, t.file_path, t.track_number, t.duration,
ar.id as artist_id, ar.name as artist_name,
al.id as album_id, al.title as album_title, al.year,
al.thumb_url as album_thumb,
t.spotify_track_id, t.itunes_track_id, t.deezer_track_id
FROM tracks t
JOIN artists ar ON ar.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '')
AND t.file_path IS NOT NULL AND t.file_path != ''
ORDER BY al.title, t.track_number
LIMIT 500
""")
tracks = [dict(row) for row in cursor.fetchall()]
finally:
conn.close()
total = len(tracks)
if total == 0:
if context.report_progress:
context.report_progress(phase='No Unknown Artist tracks found',
log_line='No tracks to fix', log_type='success')
return result
if context.report_progress:
context.report_progress(phase=f'Found {total} Unknown Artist tracks',
total=total, log_line=f'Processing {total} tracks...',
log_type='info')
# Get file path templates for reorganization
transfer = context.transfer_folder
templates = {}
if context.config_manager:
templates = context.config_manager.get('file_organization.templates', {})
album_template = templates.get('album_path', '$albumartist/$albumartist - $album/$track - $title')
for i, track in enumerate(tracks):
if context.check_stop():
return result
if i % 20 == 0 and context.wait_if_paused():
return result
result.scanned += 1
track_id = track['id']
title = track['title'] or ''
file_path = track['file_path']
# Resolve actual file on disk
from core.repair_worker import _resolve_file_path
resolved = _resolve_file_path(file_path, transfer)
if not resolved or not os.path.exists(resolved):
result.skipped += 1
continue
# Try to resolve correct metadata
corrected = self._resolve_metadata(context, track, resolved)
if not corrected:
result.skipped += 1
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,
log_line=f'Could not resolve: {title}', log_type='warning')
continue
# Compute expected file path
expected_rel = None
if reorganize_files and corrected.get('artist') and corrected.get('album'):
from core.repair_jobs.library_reorganize import _build_path_from_template, _get_audio_quality
quality = _get_audio_quality(resolved)
tmpl_ctx = {
'artist': corrected['artist'],
'albumartist': corrected['artist'],
'album': corrected['album'],
'title': corrected.get('title', title),
'track_number': corrected.get('track_number', 1),
'disc_number': corrected.get('disc_number', 1),
'year': corrected.get('year', ''),
'quality': quality,
'albumtype': 'Album',
}
folder, fname_base = _build_path_from_template(album_template, tmpl_ctx)
file_ext = os.path.splitext(resolved)[1]
if quality and f'[{quality}]' not in fname_base:
fname_base = f"{fname_base} [{quality}]"
expected_rel = os.path.join(folder, fname_base + file_ext)
if dry_run:
# Create finding for review
desc_parts = [f'Artist: Unknown Artist → {corrected["artist"]}']
if corrected.get('album'):
desc_parts.append(f'Album: {track.get("album_title", "?")}{corrected["album"]}')
if corrected.get('track_number'):
desc_parts.append(f'Track #: {track.get("track_number", "?")}{corrected["track_number"]}')
if expected_rel:
desc_parts.append(f'Path: → {expected_rel}')
if context.create_finding:
context.create_finding(
job_id=self.job_id,
finding_type='unknown_artist',
severity='warning',
entity_type='track',
entity_id=str(track_id),
file_path=file_path,
title=f'{corrected["artist"]} - {corrected.get("title", title)}',
description='\n'.join(desc_parts),
details={
'track_id': track_id,
'artist_id': track['artist_id'],
'album_id': track['album_id'],
'current_artist': track['artist_name'],
'corrected_artist': corrected['artist'],
'corrected_album': corrected.get('album', ''),
'corrected_track_number': corrected.get('track_number'),
'corrected_year': corrected.get('year', ''),
'corrected_title': corrected.get('title', title),
'source': corrected.get('source', ''),
'confidence': corrected.get('confidence', 0),
'file_path': resolved,
'expected_path': expected_rel,
'album_thumb_url': corrected.get('image_url') or track.get('album_thumb'),
'cover_url': corrected.get('image_url', ''),
}
)
result.findings_created += 1
else:
# Live mode — apply fix
try:
fixed = self._apply_fix(context, track, corrected, resolved,
expected_rel, transfer, fix_tags, reorganize_files)
if fixed:
result.auto_fixed += 1
else:
result.errors += 1
except Exception as e:
logger.error(f"Failed to fix track {track_id}: {e}")
result.errors += 1
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,
log_line=f'{"[Preview]" if dry_run else "[Fixed]"} {corrected["artist"]} - {corrected.get("title", title)}',
log_type='info' if dry_run else 'success')
if context.report_progress:
if dry_run:
context.report_progress(
phase=f'Preview complete — {result.findings_created} fixable tracks',
log_line=f'Done: {result.findings_created} can be fixed, {result.skipped} unresolvable',
log_type='success')
else:
context.report_progress(
phase=f'Fixed {result.auto_fixed} tracks',
log_line=f'Done: {result.auto_fixed} fixed, {result.errors} errors, {result.skipped} skipped',
log_type='success')
return result
def _resolve_metadata(self, context, track, resolved_path):
"""Try to resolve correct metadata for an Unknown Artist track.
Returns dict with artist, album, track_number, year, etc. or None."""
title = track['title'] or ''
# Priority 1: Read embedded file tags
try:
from core.tag_writer import read_file_tags
tags = read_file_tags(resolved_path)
tag_artist = tags.get('artist') or tags.get('album_artist')
if tag_artist and tag_artist.strip().lower() not in _UNKNOWN_NAMES:
return {
'artist': tag_artist.strip(),
'album': (tags.get('album') or '').strip() or track.get('album_title', ''),
'title': (tags.get('title') or '').strip() or title,
'track_number': tags.get('track_number') or track.get('track_number'),
'disc_number': tags.get('disc_number') or 1,
'year': (tags.get('year') or '').strip(),
'source': 'file_tags',
'confidence': 1.0,
}
except Exception as e:
logger.debug(f"Failed to read tags from {resolved_path}: {e}")
# Priority 2: Look up by source track ID
source_id = (track.get('spotify_track_id') or track.get('deezer_track_id')
or track.get('itunes_track_id'))
if source_id and context.spotify_client:
try:
details = context.spotify_client.get_track_details(str(source_id))
if details and details.get('primary_artist'):
artist = details['primary_artist']
if artist.lower() not in _UNKNOWN_NAMES:
album = details.get('album', {})
album_name = album.get('name', '') if isinstance(album, dict) else str(album)
return {
'artist': artist,
'album': album_name,
'title': details.get('name', title),
'track_number': details.get('track_number'),
'disc_number': details.get('disc_number', 1),
'year': (album.get('release_date', '') or '')[:4] if isinstance(album, dict) else '',
'image_url': album.get('images', [{}])[0].get('url', '') if isinstance(album, dict) and album.get('images') else '',
'source': 'track_id_lookup',
'confidence': 0.95,
}
except Exception as e:
logger.debug(f"Track ID lookup failed for {source_id}: {e}")
# Priority 3: Search by title
if title and context.spotify_client:
try:
results = context.spotify_client.search_tracks(title, limit=5)
if results:
# Score candidates
from difflib import SequenceMatcher
best = None
best_score = 0
for r in results:
name_sim = SequenceMatcher(None, title.lower(), r.name.lower()).ratio()
# Boost if album matches
album_name = r.album if hasattr(r, 'album') else ''
if album_name and track.get('album_title'):
album_sim = SequenceMatcher(None, track['album_title'].lower(), album_name.lower()).ratio()
name_sim = (name_sim * 0.7) + (album_sim * 0.3)
if name_sim > best_score:
best_score = name_sim
best = r
if best and best_score >= 0.7:
artist = best.artists[0] if best.artists else ''
if artist and artist.lower() not in _UNKNOWN_NAMES:
# Get full details for track_number
full_details = None
try:
full_details = context.spotify_client.get_track_details(best.id)
except Exception:
pass
album_data = full_details.get('album', {}) if full_details else {}
return {
'artist': artist,
'album': best.album if hasattr(best, 'album') else '',
'title': best.name,
'track_number': full_details.get('track_number') if full_details else None,
'disc_number': full_details.get('disc_number', 1) if full_details else 1,
'year': (album_data.get('release_date', '') or '')[:4] if isinstance(album_data, dict) else '',
'image_url': getattr(best, 'image_url', '') or '',
'source': 'title_search',
'confidence': round(best_score, 3),
}
except Exception as e:
logger.debug(f"Title search failed for '{title}': {e}")
# Rate limit courtesy
time.sleep(0.2)
return None
def _apply_fix(self, context, track, corrected, resolved_path,
expected_rel, transfer, fix_tags, reorganize_files):
"""Apply the fix: re-tag file, move to correct path, update DB."""
track_id = track['id']
# Step 1: Write corrected tags to file
if fix_tags:
try:
from core.tag_writer import write_tags_to_file
db_data = {
'title': corrected.get('title', track['title']),
'artist_name': corrected['artist'],
'album_title': corrected.get('album', ''),
'year': corrected.get('year', ''),
'track_number': corrected.get('track_number'),
'disc_number': corrected.get('disc_number', 1),
}
tag_result = write_tags_to_file(
resolved_path, db_data,
embed_cover=True,
cover_url=corrected.get('image_url') or None
)
if tag_result.get('success'):
logger.info(f"Re-tagged: {corrected['artist']} - {corrected.get('title', track['title'])}")
else:
logger.warning(f"Tag write failed for track {track_id}: {tag_result.get('error')}")
except Exception as e:
logger.error(f"Tag write error for track {track_id}: {e}")
# Step 2: Move file to correct location
final_path = resolved_path
if reorganize_files and expected_rel:
expected_abs = os.path.normpath(os.path.join(transfer, expected_rel))
current_norm = os.path.normpath(resolved_path)
if current_norm.lower() != expected_abs.lower():
try:
os.makedirs(os.path.dirname(expected_abs), exist_ok=True)
# Handle case rename on case-insensitive FS
if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs):
tmp = expected_abs + '.tmp_rename'
shutil.move(current_norm, tmp)
shutil.move(tmp, expected_abs)
else:
shutil.move(current_norm, expected_abs)
final_path = expected_abs
logger.info(f"Moved: {os.path.basename(current_norm)}{expected_rel}")
# Move sidecars
src_dir = os.path.dirname(current_norm)
dst_dir = os.path.dirname(expected_abs)
src_stem = os.path.splitext(os.path.basename(current_norm))[0]
dst_stem = os.path.splitext(os.path.basename(expected_abs))[0]
for ext in _SIDECAR_EXTS:
sidecar_src = os.path.join(src_dir, src_stem + ext)
if os.path.isfile(sidecar_src):
sidecar_dst = os.path.join(dst_dir, dst_stem + ext)
if not os.path.exists(sidecar_dst):
try:
shutil.move(sidecar_src, sidecar_dst)
except Exception:
pass
# Also move cover.jpg from old album folder
cover_src = os.path.join(src_dir, 'cover.jpg')
cover_dst = os.path.join(dst_dir, 'cover.jpg')
if os.path.isfile(cover_src) and not os.path.exists(cover_dst):
try:
shutil.copy2(cover_src, cover_dst)
except Exception:
pass
# Clean up empty directories
parent = os.path.dirname(current_norm)
transfer_norm = os.path.normpath(transfer)
for _ in range(5):
if (parent and os.path.isdir(parent)
and os.path.normpath(parent) != transfer_norm
and not os.listdir(parent)):
os.rmdir(parent)
parent = os.path.dirname(parent)
else:
break
except Exception as e:
logger.error(f"File move failed for track {track_id}: {e}")
# Continue with DB update even if move failed
# Step 3: Update database
try:
conn = context.db._get_connection()
try:
cursor = conn.cursor()
# Find or create the correct artist
corrected_artist = corrected['artist']
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)",
(corrected_artist,))
artist_row = cursor.fetchone()
if artist_row:
new_artist_id = artist_row[0]
else:
cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,))
new_artist_id = cursor.lastrowid
# Update track's artist_id and file_path
cursor.execute("""
UPDATE tracks SET artist_id = ?, file_path = ?
WHERE id = ?
""", (new_artist_id, final_path, track_id))
# Update track_number if we have it
if corrected.get('track_number'):
cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?",
(corrected['track_number'], track_id))
# Update album title if corrected
if corrected.get('album') and corrected['album'] != track.get('album_title'):
cursor.execute("UPDATE albums SET title = ? WHERE id = ?",
(corrected['album'], track['album_id']))
# Update album year if we have it
if corrected.get('year') and corrected['year'].isdigit():
cursor.execute("UPDATE albums SET year = ? WHERE id = ?",
(int(corrected['year']), track['album_id']))
# Update album artist_id to match
cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?",
(new_artist_id, track['album_id']))
conn.commit()
logger.info(f"DB updated: track {track_id} → artist '{corrected_artist}'")
finally:
conn.close()
except Exception as e:
logger.error(f"DB update failed for track {track_id}: {e}")
return False
return True
def _get_settings(self, context):
if not context.config_manager:
return self.default_settings.copy()
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
merged = self.default_settings.copy()
if isinstance(cfg, dict):
merged.update(cfg)
return merged
def _get_setting(self, context, key, default=None):
return self._get_settings(context).get(key, default)

View file

@ -161,8 +161,8 @@ class RepairWorker:
def itunes_client(self): def itunes_client(self):
if self._itunes_client is None: if self._itunes_client is None:
try: try:
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
self._itunes_client = _create_fallback_client() self._itunes_client = get_primary_client()
except Exception as e: except Exception as e:
logger.error("Failed to initialize fallback metadata client: %s", e) logger.error("Failed to initialize fallback metadata client: %s", e)
return self._itunes_client return self._itunes_client
@ -632,11 +632,11 @@ class RepairWorker:
conn = self.db._get_connection() conn = self.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Dedup check: skip if same finding already exists (pending OR recently resolved) # Dedup check: skip if same finding already exists (pending, resolved, OR dismissed)
cursor.execute(""" cursor.execute("""
SELECT id FROM repair_findings SELECT id FROM repair_findings
WHERE job_id = ? AND finding_type = ? WHERE job_id = ? AND finding_type = ?
AND status IN ('pending', 'resolved') AND status IN ('pending', 'resolved', 'dismissed')
AND ((entity_type = ? AND entity_id = ?) OR (file_path = ? AND file_path IS NOT NULL)) AND ((entity_type = ? AND entity_id = ?) OR (file_path = ? AND file_path IS NOT NULL))
LIMIT 1 LIMIT 1
""", (job_id, finding_type, entity_type, entity_id, file_path)) """, (job_id, finding_type, entity_type, entity_id, file_path))
@ -816,6 +816,7 @@ class RepairWorker:
'path_mismatch': self._fix_path_mismatch, 'path_mismatch': self._fix_path_mismatch,
'missing_lossy_copy': self._fix_missing_lossy_copy, 'missing_lossy_copy': self._fix_missing_lossy_copy,
'unwanted_content': self._fix_unwanted_content, 'unwanted_content': self._fix_unwanted_content,
'unknown_artist': self._fix_unknown_artist,
} }
handler = handlers.get(finding_type) handler = handlers.get(finding_type)
if not handler: if not handler:
@ -1382,6 +1383,109 @@ class RepairWorker:
msg += ' (file deleted)' msg += ' (file deleted)'
return {'success': True, 'action': 'removed_content', 'message': msg} return {'success': True, 'action': 'removed_content', 'message': msg}
def _fix_unknown_artist(self, entity_type, entity_id, file_path, details):
"""Fix an Unknown Artist track — re-tag, move to correct path, update DB."""
track_id = details.get('track_id')
corrected_artist = details.get('corrected_artist', '')
corrected_album = details.get('corrected_album', '')
corrected_title = details.get('corrected_title', '')
corrected_track_number = details.get('corrected_track_number')
corrected_year = details.get('corrected_year', '')
cover_url = details.get('cover_url', '')
expected_path = details.get('expected_path', '')
if not corrected_artist or not track_id:
return {'success': False, 'error': 'Missing corrected artist or track ID'}
# Resolve file
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else ''
resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder) if file_path else None
if not resolved or not os.path.exists(resolved):
return {'success': False, 'error': f'File not found: {file_path}'}
# Step 1: Re-tag file
try:
from core.tag_writer import write_tags_to_file
db_data = {
'title': corrected_title,
'artist_name': corrected_artist,
'album_title': corrected_album,
'year': corrected_year,
'track_number': corrected_track_number,
}
write_tags_to_file(resolved, db_data, embed_cover=bool(cover_url), cover_url=cover_url or None)
except Exception as e:
logger.warning(f"Tag write failed during unknown artist fix: {e}")
# Step 2: Move file if expected path differs
final_path = resolved
if expected_path:
expected_abs = os.path.normpath(os.path.join(self.transfer_folder, expected_path))
if os.path.normpath(resolved).lower() != expected_abs.lower():
try:
os.makedirs(os.path.dirname(expected_abs), exist_ok=True)
if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs):
tmp = expected_abs + '.tmp_rename'
shutil.move(resolved, tmp)
shutil.move(tmp, expected_abs)
else:
shutil.move(resolved, expected_abs)
final_path = expected_abs
# Move sidecars
src_dir = os.path.dirname(resolved)
dst_dir = os.path.dirname(expected_abs)
src_stem = os.path.splitext(os.path.basename(resolved))[0]
dst_stem = os.path.splitext(os.path.basename(expected_abs))[0]
for ext in ('.lrc', '.jpg', '.jpeg', '.png', '.txt'):
s = os.path.join(src_dir, src_stem + ext)
if os.path.isfile(s):
d = os.path.join(dst_dir, dst_stem + ext)
if not os.path.exists(d):
try:
shutil.move(s, d)
except Exception:
pass
# Clean up empty dirs
self._cleanup_empty_parents(resolved)
except Exception as e:
logger.error(f"File move failed: {e}")
# Step 3: Update DB
try:
conn = self.db._get_connection()
try:
cursor = conn.cursor()
# Find or create artist
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (corrected_artist,))
row = cursor.fetchone()
new_artist_id = row[0] if row else None
if not new_artist_id:
cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,))
new_artist_id = cursor.lastrowid
cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?",
(new_artist_id, final_path, track_id))
if corrected_track_number:
cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?",
(corrected_track_number, track_id))
album_id = details.get('album_id')
if album_id:
if corrected_album:
cursor.execute("UPDATE albums SET title = ? WHERE id = ?", (corrected_album, album_id))
if corrected_year and corrected_year.isdigit():
cursor.execute("UPDATE albums SET year = ? WHERE id = ?", (int(corrected_year), album_id))
cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", (new_artist_id, album_id))
conn.commit()
finally:
conn.close()
except Exception as e:
return {'success': False, 'error': f'DB update failed: {e}'}
return {'success': True, 'action': 'fixed_unknown_artist',
'message': f'Fixed: {corrected_artist} - {corrected_title}'}
def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details): def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details):
"""Remove the mismatched MusicBrainz recording ID from the audio file.""" """Remove the mismatched MusicBrainz recording ID from the audio file."""
if not file_path: if not file_path:

View file

@ -96,14 +96,9 @@ class SeasonalDiscoveryService:
self._ensure_database_schema() self._ensure_database_schema()
def _get_source(self): def _get_source(self):
"""Determine active music source (matches _get_active_discovery_source in web_server)""" """Determine active music source — delegates to centralized metadata_service."""
if self.spotify_client and self.spotify_client.is_spotify_authenticated(): from core.metadata_service import get_primary_source
return 'spotify' return get_primary_source()
try:
from core.metadata_service import _get_configured_fallback_source
return _get_configured_fallback_source()
except Exception:
return 'itunes'
def _ensure_database_schema(self): def _ensure_database_schema(self):
"""Create seasonal content tables if they don't exist""" """Create seasonal content tables if they don't exist"""
@ -447,14 +442,14 @@ class SeasonalDiscoveryService:
seasonal_albums = [] seasonal_albums = []
source = self._get_source() source = self._get_source()
use_spotify = self.spotify_client and self.spotify_client.is_authenticated() use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated()
# IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety # IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety
sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists))) sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists)))
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source from core.metadata_service import get_primary_client, get_primary_source
fallback_client = _create_fallback_client() fallback_client = get_primary_client()
fallback_source = _get_configured_fallback_source() fallback_source = get_primary_source()
for artist in sampled_artists: for artist in sampled_artists:
try: try:
@ -512,7 +507,7 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key] config = SEASONAL_CONFIG[season_key]
keywords = config['keywords'] keywords = config['keywords']
source = self._get_source() source = self._get_source()
use_spotify = self.spotify_client and self.spotify_client.is_authenticated() use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated()
seasonal_albums = [] seasonal_albums = []
seen_album_ids = set() seen_album_ids = set()
@ -556,8 +551,8 @@ class SeasonalDiscoveryService:
continue continue
else: else:
# Fallback metadata source (iTunes or Deezer) # Fallback metadata source (iTunes or Deezer)
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
fallback_client = _create_fallback_client() fallback_client = get_primary_client()
for keyword in search_keywords: for keyword in search_keywords:
try: try:
@ -772,10 +767,10 @@ class SeasonalDiscoveryService:
# Get tracks from seasonal albums (filtered by source) # Get tracks from seasonal albums (filtered by source)
seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source) seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source)
use_spotify = self.spotify_client and self.spotify_client.is_authenticated() use_spotify = (source == 'spotify') and self.spotify_client and self.spotify_client.is_spotify_authenticated()
if not use_spotify: if not use_spotify:
from core.metadata_service import _create_fallback_client from core.metadata_service import get_primary_client
fallback_client = _create_fallback_client() fallback_client = get_primary_client()
for album in seasonal_albums: for album in seasonal_albums:
try: try:

View file

@ -506,11 +506,11 @@ class SpotifyClient:
@property @property
def _fallback_source(self) -> str: def _fallback_source(self) -> str:
"""Get configured metadata fallback source ('itunes', 'deezer', or 'discogs')""" """Get configured primary metadata source for internal fallback routing."""
try: try:
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
except Exception: except Exception:
return 'itunes' return 'deezer'
@property @property
def _fallback(self): def _fallback(self):

View file

@ -30,18 +30,9 @@ ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff
def _get_fallback_metadata_client(): def _get_fallback_metadata_client():
"""Get the configured metadata fallback client (iTunes or Deezer).""" """Get the configured metadata client — delegates to centralized metadata_service."""
try: from core.metadata_service import get_primary_source, get_primary_client
from config.settings import config_manager return get_primary_client(), get_primary_source()
source = config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
if source == 'deezer':
from core.deezer_client import DeezerClient
return DeezerClient(), 'deezer'
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
except Exception:
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs): def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs):

View file

@ -561,6 +561,76 @@ class YouTubeClient:
return track_result return track_result
async def search_videos(self, query: str, max_results: int = 20) -> List[YouTubeSearchResult]:
"""Search YouTube and return video metadata for music video display.
Unlike search() which returns TrackResult objects for download matching,
this returns YouTubeSearchResult objects with video-specific metadata
(thumbnails, view counts, channel names) for UI display.
"""
logger.info(f"🎬 Searching YouTube videos for: {query}")
try:
loop = asyncio.get_event_loop()
def _search():
from config.settings import config_manager
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'default_search': 'ytsearch',
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
data = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
if not data or 'entries' not in data:
return []
results = []
for entry in data['entries']:
if not entry:
continue
video_id = entry.get('id', '')
title = entry.get('title', '')
if not video_id or not title:
continue
# Skip very short clips (< 30s) and very long content (> 15min)
duration = entry.get('duration') or 0
if duration < 30 or duration > 900:
continue
channel = entry.get('uploader', entry.get('channel', ''))
if channel and re.search(r'\s*-\s*Topic\s*$', channel, re.IGNORECASE):
channel = re.sub(r'\s*-\s*Topic\s*$', '', channel, flags=re.IGNORECASE).strip()
thumbnail = entry.get('thumbnail')
if not thumbnail and entry.get('thumbnails'):
thumbs = entry['thumbnails']
if isinstance(thumbs, list) and thumbs:
thumbnail = thumbs[-1].get('url')
results.append(YouTubeSearchResult(
video_id=video_id,
title=title,
channel=channel,
duration=duration,
url=f"https://www.youtube.com/watch?v={video_id}",
thumbnail=thumbnail or '',
view_count=entry.get('view_count', 0) or 0,
upload_date=entry.get('upload_date', ''),
))
return results
return await loop.run_in_executor(None, _search)
except Exception as e:
logger.error(f"YouTube video search failed: {e}")
return []
async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]: async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]:
""" """
Search YouTube for tracks matching the query (async, Soulseek-compatible interface). Search YouTube for tracks matching the query (async, Soulseek-compatible interface).
@ -1000,6 +1070,65 @@ class YouTubeClient:
traceback.print_exc() traceback.print_exc()
return None return None
def download_music_video(self, video_url: str, output_path: str,
progress_callback=None) -> Optional[str]:
"""Download a YouTube video as a music video file (keeps video, not audio-only).
Args:
video_url: YouTube video URL
output_path: Full path for the output file (without extension yt-dlp adds it)
progress_callback: Optional callback(percent: float) for progress updates
Returns:
Final file path if successful, None otherwise
"""
try:
from config.settings import config_manager
def _progress_hook(d):
if progress_callback and d.get('status') == 'downloading':
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
downloaded = d.get('downloaded_bytes', 0)
if total > 0:
progress_callback(downloaded / total * 100)
download_opts = {
'quiet': True,
'no_warnings': True,
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'merge_output_format': 'mp4',
'outtmpl': output_path + '.%(ext)s',
'noplaylist': True,
'progress_hooks': [_progress_hook],
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
download_opts['cookiesfrombrowser'] = (cookies_browser,)
with yt_dlp.YoutubeDL(download_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
final_path = Path(ydl.prepare_filename(info))
# yt-dlp may have merged to mp4
mp4_path = final_path.with_suffix('.mp4')
if mp4_path.exists():
return str(mp4_path)
if final_path.exists():
return str(final_path)
# Check for any file matching the stem
for f in final_path.parent.glob(f"{final_path.stem}.*"):
if f.suffix in ('.mp4', '.mkv', '.webm'):
return str(f)
logger.error(f"Music video download completed but file not found: {final_path}")
return None
except Exception as e:
logger.error(f"Music video download failed: {e}")
import traceback
traceback.print_exc()
return None
async def get_all_downloads(self) -> List[DownloadStatus]: async def get_all_downloads(self) -> List[DownloadStatus]:
""" """
Get all active downloads (matches Soulseek interface). Get all active downloads (matches Soulseek interface).

View file

@ -551,6 +551,40 @@ class MusicDatabase:
except Exception: except Exception:
pass pass
# One-time migration: purge discovery cache entries that lack track_number.
# Prior versions cached discovery results without track_number/disc_number/release_date,
# causing incorrect file organization (all tracks as "01", missing album year).
# Purged entries get re-populated with complete data on next discovery.
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_discovery_cache_v2_migrated'")
if not cursor.fetchone():
cursor.execute("DELETE FROM discovery_match_cache WHERE id IN ("
"SELECT id FROM discovery_match_cache WHERE "
"matched_data_json NOT LIKE '%track_number%')")
purged = cursor.rowcount
cursor.execute("CREATE TABLE _discovery_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
if purged > 0:
logger.info(f"Purged {purged} stale discovery cache entries (missing track_number)")
except Exception:
pass
# One-time migration: purge Deezer album/track cache entries with missing data.
# Deezer's /artist/{id}/albums returns albums without artist info, and search
# results cache tracks without track_position — both produce bad metadata.
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_deezer_cache_v2_migrated'")
if not cursor.fetchone():
cursor.execute("""DELETE FROM metadata_cache_entities
WHERE source = 'deezer' AND entity_type IN ('album', 'track')""")
purged = cursor.rowcount
cursor.execute("""DELETE FROM metadata_cache_searches
WHERE source = 'deezer' AND search_type IN ('album', 'track')""")
cursor.execute("CREATE TABLE _deezer_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
if purged > 0:
logger.info(f"Purged {purged} stale Deezer cache entries (missing artist/track_position)")
except Exception:
pass
conn.commit() conn.commit()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")

View file

@ -30,6 +30,7 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
- ./downloads:/app/downloads - ./downloads:/app/downloads
- ./Staging:/app/Staging - ./Staging:/app/Staging
- ./MusicVideos:/app/MusicVideos
- ./scripts:/app/scripts - ./scripts:/app/scripts
# Use named volume for database persistence (separate from host database) # Use named volume for database persistence (separate from host database)
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package # NOTE: Changed from /app/database to /app/data to avoid overwriting Python package

View file

@ -855,13 +855,18 @@ def _register_automation_handlers():
md = extra['matched_data'] md = extra['matched_data']
album_raw = md.get('album', '') album_raw = md.get('album', '')
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
tracks_json.append({ _track_entry = {
'name': md.get('name', ''), 'name': md.get('name', ''),
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]), 'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
'album': album_obj, 'album': album_obj,
'duration_ms': md.get('duration_ms', 0), 'duration_ms': md.get('duration_ms', 0),
'id': md.get('id', ''), 'id': md.get('id', ''),
}) }
if md.get('track_number'):
_track_entry['track_number'] = md['track_number']
if md.get('disc_number'):
_track_entry['disc_number'] = md['disc_number']
tracks_json.append(_track_entry)
else: else:
# NOT discovered — try to include using available metadata so the # NOT discovered — try to include using available metadata so the
# track can still be searched on Soulseek and added to wishlist. # track can still be searched on Soulseek and added to wishlist.
@ -5179,7 +5184,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']: 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']:
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)
@ -7868,6 +7873,8 @@ def enhanced_search():
alternate_sources.append('discogs') alternate_sources.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available: if primary_source != 'hydrabase' and hydrabase_available:
alternate_sources.append('hydrabase') alternate_sources.append('hydrabase')
# YouTube music videos always available (uses yt-dlp, no auth needed)
alternate_sources.append('youtube_videos')
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, " logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, " f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
@ -7954,7 +7961,7 @@ def enhanced_search_source(source_name):
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI. This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
Falls back to single JSON response if streaming not supported. Falls back to single JSON response if streaming not supported.
""" """
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'): if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'):
return jsonify({"error": f"Unknown source: {source_name}"}), 400 return jsonify({"error": f"Unknown source: {source_name}"}), 400
data = request.get_json() data = request.get_json()
@ -7962,6 +7969,37 @@ def enhanced_search_source(source_name):
if not query: if not query:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
# YouTube music videos — separate flow from metadata sources
if source_name == 'youtube_videos':
if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube:
return jsonify({"videos": [], "available": False})
try:
def generate_videos():
try:
# Search YouTube via yt-dlp
video_query = f"{query} official music video"
results = run_async(soulseek_client.youtube.search_videos(video_query, max_results=20))
videos = []
for v in (results or []):
videos.append({
'video_id': v.video_id,
'title': v.title,
'channel': v.channel,
'duration': v.duration,
'thumbnail': v.thumbnail,
'url': v.url,
'view_count': v.view_count,
'upload_date': v.upload_date,
})
yield json.dumps({"type": "videos", "data": videos}) + "\n"
except Exception as e:
logger.error(f"YouTube music video search failed: {e}")
yield json.dumps({"type": "videos", "data": []}) + "\n"
yield json.dumps({"type": "done"}) + "\n"
return app.response_class(generate_videos(), mimetype='application/x-ndjson')
except Exception as e:
return jsonify({"error": str(e)}), 500
try: try:
client = None client = None
if source_name == 'spotify': if source_name == 'spotify':
@ -8308,6 +8346,138 @@ def stream_enhanced_search_track():
logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True) logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# =============================================================================
# MUSIC VIDEO DOWNLOADS
# =============================================================================
_music_video_downloads = {} # {video_id: {status, progress, path, error}}
@app.route('/api/music-video/download', methods=['POST'])
def download_music_video():
"""Download a YouTube video as a music video file to the configured music videos folder."""
data = request.get_json()
if not data:
return jsonify({"error": "No data"}), 400
video_id = data.get('video_id', '')
video_url = data.get('url', '')
raw_title = data.get('title', '')
raw_channel = data.get('channel', '')
if not video_id or not video_url:
return jsonify({"error": "Missing video_id or url"}), 400
# Check if already downloading
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')
music_videos_path = docker_resolve_path(music_videos_path)
os.makedirs(music_videos_path, exist_ok=True)
# Initialize download state
_music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None}
def _do_download():
try:
# Step 1: Try to match against primary metadata source for clean artist/title
_music_video_downloads[video_id]['status'] = 'matching'
artist_name = raw_channel
track_title = raw_title
# Strip common YouTube suffixes for cleaner search
import re as _re
clean_search = _re.sub(r'\s*[\(\[](official\s*(music\s*)?video|official\s*lyric\s*video|official\s*audio|official\s*hd|hd|4k|remastered|lyric\s*video|visualizer|audio)[\)\]]', '', raw_title, flags=_re.IGNORECASE).strip()
clean_search = _re.sub(r'\s*-\s*$', '', clean_search).strip()
try:
fallback_client = _get_metadata_fallback_client()
results = fallback_client.search_tracks(clean_search, limit=5)
if results:
from difflib import SequenceMatcher
best = None
best_score = 0
for r in results:
name_sim = SequenceMatcher(None, clean_search.lower(), r.name.lower()).ratio()
if r.artists:
artist_sim = SequenceMatcher(None, raw_channel.lower(), r.artists[0].lower()).ratio()
name_sim = (name_sim * 0.6) + (artist_sim * 0.4)
if name_sim > best_score:
best_score = name_sim
best = r
if best and best_score >= 0.5:
artist_name = best.artists[0] if best.artists else raw_channel
track_title = best.name
print(f"🎬 [Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})")
else:
# Parse artist from video title: "Artist - Title" pattern
if ' - ' in raw_title:
parts = raw_title.split(' - ', 1)
artist_name = parts[0].strip()
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
print(f"🎬 [Music Video] No metadata match, using parsed: {artist_name} - {track_title}")
except Exception as e:
print(f"⚠️ [Music Video] Metadata lookup failed: {e}")
if ' - ' in raw_title:
parts = raw_title.split(' - ', 1)
artist_name = parts[0].strip()
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
# Sanitize for filesystem
def _sanitize(s):
return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.')
artist_folder = _sanitize(artist_name)
video_filename = f"{_sanitize(track_title)}-video"
# Build output path: MusicVideos/Artist/Title-video
artist_dir = os.path.join(music_videos_path, artist_folder)
os.makedirs(artist_dir, exist_ok=True)
output_path = os.path.join(artist_dir, video_filename)
# Step 2: Download
_music_video_downloads[video_id]['status'] = 'downloading'
_music_video_downloads[video_id]['artist'] = artist_name
_music_video_downloads[video_id]['title'] = track_title
def _progress(pct):
_music_video_downloads[video_id]['progress'] = round(pct, 1)
final_path = soulseek_client.youtube.download_music_video(video_url, output_path, progress_callback=_progress)
if final_path and os.path.exists(final_path):
_music_video_downloads[video_id]['status'] = 'completed'
_music_video_downloads[video_id]['progress'] = 100
_music_video_downloads[video_id]['path'] = final_path
print(f"✅ [Music Video] Downloaded: {artist_name} - {track_title}{final_path}")
add_activity_item("🎬", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now")
else:
_music_video_downloads[video_id]['status'] = 'error'
_music_video_downloads[video_id]['error'] = 'Download failed — file not found'
print(f"❌ [Music Video] Download failed for: {artist_name} - {track_title}")
except Exception as e:
_music_video_downloads[video_id]['status'] = 'error'
_music_video_downloads[video_id]['error'] = str(e)
print(f"❌ [Music Video] Error: {e}")
# Run in background thread
import threading
threading.Thread(target=_do_download, daemon=True, name=f'music-video-{video_id}').start()
return jsonify({"success": True, "video_id": video_id})
@app.route('/api/music-video/status/<video_id>', methods=['GET'])
def get_music_video_status(video_id):
"""Get download status for a music video."""
status = _music_video_downloads.get(video_id)
if not status:
return jsonify({"status": "unknown"})
return jsonify(status)
@app.route('/api/download', methods=['POST']) @app.route('/api/download', methods=['POST'])
def start_download(): def start_download():
"""Simple download route""" """Simple download route"""
@ -21116,6 +21286,32 @@ 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": "🔧 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",
"features": [
"• Fixed playlist pipeline (discover → sync → wishlist → download) losing artist, track number, and album year data",
"• All discovery workers now respect your configured primary metadata source instead of always using Spotify",
"• Centralized metadata source selection in core/metadata_service.py — one source of truth for all features",
"• Fixed Deezer metadata cache returning incomplete data (missing track_number, release_date) from search result cache",
"• Sync completion toast now shows which specific tracks failed to match (not just a count)",
"• New 'Fix Unknown Artists' maintenance job — scans library for Unknown Artist tracks and corrects metadata, tags, and file paths",
"• One-time migration purges stale discovery and Deezer cache entries on first startup after update"
],
"usage_note": "If you have existing Unknown Artist tracks, run the Fix Unknown Artists job from Settings > Maintenance."
},
{
"title": "🛡️ Matching Engine — Artist Verification Gate",
"description": "Prevents downloading tracks from completely wrong artists on Soulseek and YouTube",
"features": [
"• New artist gate rejects candidates where the artist doesn't match the target (Soulseek: < 0.25, YouTube: < 0.15)",
"• Fixed artist substring matching — 'muse' no longer matches 'museum', 'art' no longer matches 'heart'",
"• Artist similarity now compared per path segment instead of full filename — misspelled artist names still match correctly",
"• YouTube artist weight increased from 10% to 20% to reduce wrong-uploader matches",
"• Seasonal discovery, personalized playlists, and playlist explorer all use configured source instead of Spotify"
],
"usage_note": "No action needed — matching improvements apply automatically to all new downloads."
},
{ {
"title": "🎵 Deezer User Playlists — Browse & Download Your Library", "title": "🎵 Deezer User Playlists — Browse & Download Your Library",
"description": "New Deezer tab on the Sync page shows your personal playlists via ARL token — same flow as Spotify", "description": "New Deezer tab on the Sync page shows your personal playlists via ARL token — same flow as Spotify",
@ -26981,6 +27177,9 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
sp_data = {} sp_data = {}
album_val = sp_data.get('album') album_val = sp_data.get('album')
album_id = album_val.get('id') if isinstance(album_val, dict) else album_val if isinstance(album_val, str) else None album_id = album_val.get('id') if isinstance(album_val, dict) else album_val if isinstance(album_val, str) else None
# Fallback album key: use album name when ID is missing (e.g. mirrored playlist tracks)
if not album_id and isinstance(album_val, dict) and album_val.get('name'):
album_id = f"_name_{album_val['name'].lower().strip()}"
disc_num = sp_data.get('disc_number', t.get('disc_number', 1)) disc_num = sp_data.get('disc_number', t.get('disc_number', 1))
if album_id: if album_id:
wishlist_album_disc_counts[album_id] = max( wishlist_album_disc_counts[album_id] = max(
@ -27013,7 +27212,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
_fa = _wl_track_artists[0] _fa = _wl_track_artists[0]
wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)} wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)}
else: else:
wishlist_album_artist_map[album_id] = {'name': t.get('artist', 'Unknown Artist')} # Try top-level 'artists' (wishlist format uses plural)
_tl_artists = t.get('artists', [])
if _tl_artists:
_tla = _tl_artists[0]
_fallback_name = _tla.get('name', str(_tla)) if isinstance(_tla, dict) else str(_tla)
else:
_fallback_name = t.get('artist', '')
wishlist_album_artist_map[album_id] = {'name': _fallback_name or 'Unknown Artist'}
print(f"🔗 [Wishlist Album Grouping] Album '{_wl_album.get('name', album_id)}' → artist: '{wishlist_album_artist_map[album_id].get('name', '?')}'") print(f"🔗 [Wishlist Album Grouping] Album '{_wl_album.get('name', album_id)}' → artist: '{wishlist_album_artist_map[album_id].get('name', '?')}'")
@ -27053,11 +27259,22 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
# Use pre-computed album-level artist for folder consistency. # Use pre-computed album-level artist for folder consistency.
# All tracks from the same album get the same artist context, # All tracks from the same album get the same artist context,
# preventing folder splits on collab albums (KPOP Demon Hunters, etc.) # preventing folder splits on collab albums (KPOP Demon Hunters, etc.)
album_id_for_lookup = s_album.get('id', 'wishlist_album') album_id_for_lookup = s_album.get('id')
# Fallback album key: match first-pass logic for missing IDs
if not album_id_for_lookup and s_album.get('name'):
album_id_for_lookup = f"_name_{s_album['name'].lower().strip()}"
if not album_id_for_lookup:
album_id_for_lookup = 'wishlist_album'
artist_ctx = wishlist_album_artist_map.get(album_id_for_lookup, {}) artist_ctx = wishlist_album_artist_map.get(album_id_for_lookup, {})
if not artist_ctx or not artist_ctx.get('name'): if not artist_ctx or not artist_ctx.get('name'):
# Fallback: per-track resolution (shouldn't happen, but safety) # Fallback: per-track resolution from artists array
artist_ctx = {'name': track_info.get('artist', 'Unknown Artist')} _fb_artists = track_info.get('artists', [])
if _fb_artists:
_fb_a = _fb_artists[0]
_fb_name = _fb_a.get('name', str(_fb_a)) if isinstance(_fb_a, dict) else str(_fb_a)
else:
_fb_name = track_info.get('artist', '')
artist_ctx = {'name': _fb_name or 'Unknown Artist'}
# Construct minimal album context # Construct minimal album context
# Ensure images are preserved (important for artwork) # Ensure images are preserved (important for artwork)
@ -28076,20 +28293,21 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None)
got_track_number = True got_track_number = True
print(f"🔢 [Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}") print(f"🔢 [Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
# Backfill album metadata from detailed track when fallback path # Backfill album metadata from detailed track when context
# produced incomplete data # has incomplete data (missing release_date, total_tracks, etc.)
if not has_explicit_context and isinstance(detailed_track.get('album'), dict): if isinstance(detailed_track.get('album'), dict):
dt_album = detailed_track['album'] dt_album = detailed_track['album']
if not spotify_album_context.get('release_date') and dt_album.get('release_date'): if not spotify_album_context.get('release_date') and dt_album.get('release_date'):
spotify_album_context['release_date'] = dt_album['release_date'] spotify_album_context['release_date'] = dt_album['release_date']
print(f"📅 [Context] Backfilled release_date from API: {dt_album['release_date']}") print(f"📅 [Context] Backfilled release_date from API: {dt_album['release_date']}")
if dt_album.get('album_type') and not fallback_album.get('album_type'): if not spotify_album_context.get('album_type') and dt_album.get('album_type'):
spotify_album_context['album_type'] = dt_album['album_type'] spotify_album_context['album_type'] = dt_album['album_type']
if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'): if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'):
spotify_album_context['total_tracks'] = dt_album['total_tracks'] spotify_album_context['total_tracks'] = dt_album['total_tracks']
if not spotify_album_context.get('name') or spotify_album_context['name'] == track.album: if not spotify_album_context.get('id') and dt_album.get('id'):
if dt_album.get('name'): spotify_album_context['id'] = dt_album['id']
spotify_album_context['name'] = dt_album['name'] if not spotify_album_context.get('image_url') and dt_album.get('images'):
spotify_album_context['image_url'] = dt_album['images'][0].get('url', '')
except Exception as e: except Exception as e:
print(f"⚠️ [Context] API track details failed: {e}") print(f"⚠️ [Context] API track details failed: {e}")
@ -32386,8 +32604,8 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
_ew_state = {} _ew_state = {}
try: try:
_ew_state = _pause_enrichment_workers('mirrored playlist discovery') _ew_state = _pause_enrichment_workers('mirrored playlist discovery')
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
itunes_client_instance = None itunes_client_instance = None
if not use_spotify: if not use_spotify:
@ -32441,8 +32659,20 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
if existing_extra.get('discovered'): if existing_extra.get('discovered'):
# Check if matched_data is complete — old discoveries may be missing
# track_number/release_date due to the Track dataclass stripping them.
# Re-discover these so the enriched pipeline fills in the gaps.
md = existing_extra.get('matched_data', {})
album = md.get('album', {})
has_track_num = md.get('track_number')
has_release = album.get('release_date') if isinstance(album, dict) else None
has_album_id = album.get('id') if isinstance(album, dict) else None
if has_track_num and (has_release or has_album_id):
pl_skipped += 1 pl_skipped += 1
total_skipped += 1 total_skipped += 1
else:
# Incomplete discovery — re-discover to get full metadata
undiscovered_tracks.append(track)
else: else:
undiscovered_tracks.append(track) undiscovered_tracks.append(track)
@ -32562,6 +32792,35 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
album_obj = {'name': album_name, 'release_date': getattr(best_match, 'release_date', '') or ''} album_obj = {'name': album_name, 'release_date': getattr(best_match, 'release_date', '') or ''}
if match_image: if match_image:
album_obj['images'] = [{'url': match_image, 'height': 600, 'width': 600}] album_obj['images'] = [{'url': match_image, 'height': 600, 'width': 600}]
# Enrich album data from metadata cache — search_tracks() caches the
# raw API response which has full album info (id, images, total_tracks)
# that the Track dataclass strips to just a name string
track_number = None
disc_number = None
if hasattr(best_match, 'id') and best_match.id:
try:
_raw = cache.get_entity(discovery_source if not use_spotify else 'spotify', 'track', best_match.id)
if _raw and isinstance(_raw.get('album'), dict):
_raw_album = _raw['album']
if _raw_album.get('id'):
album_obj['id'] = _raw_album['id']
if _raw_album.get('images') and not album_obj.get('images'):
album_obj['images'] = _raw_album['images']
if _raw_album.get('total_tracks'):
album_obj['total_tracks'] = _raw_album['total_tracks']
if _raw_album.get('album_type'):
album_obj['album_type'] = _raw_album['album_type']
if _raw_album.get('release_date') and not album_obj.get('release_date'):
album_obj['release_date'] = _raw_album['release_date']
if _raw_album.get('artists'):
album_obj['artists'] = _raw_album['artists']
if _raw:
track_number = _raw.get('track_number')
disc_number = _raw.get('disc_number')
except Exception:
pass
matched_data = { matched_data = {
'id': best_match.id if hasattr(best_match, 'id') else '', 'id': best_match.id if hasattr(best_match, 'id') else '',
'name': best_match.name if hasattr(best_match, 'name') else '', 'name': best_match.name if hasattr(best_match, 'name') else '',
@ -32571,6 +32830,10 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
'image_url': match_image, 'image_url': match_image,
'source': discovery_source, 'source': discovery_source,
} }
if track_number:
matched_data['track_number'] = track_number
if disc_number:
matched_data['disc_number'] = disc_number
extra_data = { extra_data = {
'discovered': True, 'discovered': True,
@ -32799,9 +33062,9 @@ def _run_tidal_discovery_worker(playlist_id):
state = tidal_discovery_states[playlist_id] state = tidal_discovery_states[playlist_id]
playlist = state['playlist'] playlist = state['playlist']
# Determine which provider to use # Determine which provider to use — respect user's configured primary source
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Initialize fallback client if needed # Initialize fallback client if needed
itunes_client_instance = None itunes_client_instance = None
@ -32900,6 +33163,11 @@ def _run_tidal_discovery_worker(playlist_id):
'image_url': _image_url, 'image_url': _image_url,
'source': 'spotify' 'source': 'spotify'
} }
# Preserve track_number/disc_number from raw Spotify API data
if raw_track_data and raw_track_data.get('track_number'):
match_data['track_number'] = raw_track_data['track_number']
if raw_track_data and raw_track_data.get('disc_number'):
match_data['disc_number'] = raw_track_data['disc_number']
result['spotify_data'] = match_data result['spotify_data'] = match_data
result['match_data'] = match_data result['match_data'] = match_data
result['status'] = 'found' result['status'] = 'found'
@ -33112,20 +33380,56 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client
track_id = best_match.id if hasattr(best_match, 'id') else '' track_id = best_match.id if hasattr(best_match, 'id') else ''
duration_ms = best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0 duration_ms = best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0
return { # Fetch full track details to get album ID, track_number, etc.
'id': track_id, # The Track dataclass strips this data — the API has it
'name': result_name, album_obj = {
'artists': [result_artist],
'album': {
'name': album_name, 'name': album_name,
'album_type': 'album', 'album_type': 'album',
'release_date': getattr(best_match, 'release_date', '') or '', 'release_date': getattr(best_match, 'release_date', '') or '',
'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else [] 'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else []
}, }
track_number = None
disc_number = None
if track_id:
try:
detailed = itunes_client.get_track_details(track_id)
if detailed and isinstance(detailed.get('album'), dict):
dt_album = detailed['album']
if dt_album.get('id'):
album_obj['id'] = dt_album['id']
if dt_album.get('total_tracks'):
album_obj['total_tracks'] = dt_album['total_tracks']
if dt_album.get('release_date') and not album_obj.get('release_date'):
album_obj['release_date'] = dt_album['release_date']
if dt_album.get('album_type'):
album_obj['album_type'] = dt_album['album_type']
if dt_album.get('images') and not album_obj.get('images'):
album_obj['images'] = dt_album['images']
if dt_album.get('artists'):
album_obj['artists'] = dt_album['artists']
if detailed:
track_number = detailed.get('track_number')
disc_number = detailed.get('disc_number')
print(f"🔢 [Discovery Enrich] {result_name}: track_number={track_number}, disc={disc_number}")
else:
print(f"⚠️ [Discovery Enrich] get_track_details returned None for ID {track_id} ({result_name})")
except Exception as _enrich_err:
print(f"⚠️ [Discovery Enrich] Failed for {result_name} (ID {track_id}): {_enrich_err}")
result_data = {
'id': track_id,
'name': result_name,
'artists': [result_artist],
'album': album_obj,
'duration_ms': duration_ms, 'duration_ms': duration_ms,
'source': _get_metadata_fallback_source(), 'source': _get_metadata_fallback_source(),
'confidence': best_confidence 'confidence': best_confidence
} }
if track_number:
result_data['track_number'] = track_number
if disc_number:
result_data['disc_number'] = disc_number
return result_data
else: else:
print(f"❌ No suitable Tidal match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})") print(f"❌ No suitable Tidal match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})")
return None return None
@ -33152,6 +33456,10 @@ def convert_tidal_results_to_spotify_tracks(discovery_results):
'album': spotify_data['album'], 'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0) 'duration_ms': spotify_data.get('duration_ms', 0)
} }
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track) spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
# Build from individual fields (automatic discovery format) # Build from individual fields (automatic discovery format)
@ -33326,11 +33634,12 @@ def _get_deezer_client():
def _get_metadata_fallback_source(): def _get_metadata_fallback_source():
"""Get the configured primary metadata source. """Get the configured primary metadata source.
Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.""" Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.
try:
return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' NOTE: This is a thin wrapper canonical logic lives in core.metadata_service.get_primary_source().
except Exception: Kept as a local function because 70+ callers reference it by name."""
return 'deezer' from core.metadata_service import get_primary_source
return get_primary_source()
def _get_metadata_fallback_client(): def _get_metadata_fallback_client():
"""Get the active metadata client based on settings. """Get the active metadata client based on settings.
@ -33818,8 +34127,8 @@ def _run_deezer_discovery_worker(playlist_id):
playlist = state['playlist'] playlist = state['playlist']
# Determine which provider to use # Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Initialize fallback client if needed # Initialize fallback client if needed
itunes_client_instance = None itunes_client_instance = None
@ -33956,6 +34265,11 @@ def _run_deezer_discovery_worker(playlist_id):
'image_url': _image_url, 'image_url': _image_url,
'source': 'spotify' 'source': 'spotify'
} }
# Preserve track_number/disc_number from raw Spotify API data
if raw_track_data and raw_track_data.get('track_number'):
match_data['track_number'] = raw_track_data['track_number']
if raw_track_data and raw_track_data.get('disc_number'):
match_data['disc_number'] = raw_track_data['disc_number']
result['spotify_data'] = match_data result['spotify_data'] = match_data
result['match_data'] = match_data result['match_data'] = match_data
result['status'] = '✅ Found' result['status'] = '✅ Found'
@ -34072,6 +34386,10 @@ def convert_deezer_results_to_spotify_tracks(discovery_results):
'album': spotify_data['album'], 'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0) 'duration_ms': spotify_data.get('duration_ms', 0)
} }
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track) spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
track = { track = {
@ -34636,9 +34954,9 @@ def _run_spotify_public_discovery_worker(url_hash):
state = spotify_public_discovery_states[url_hash] state = spotify_public_discovery_states[url_hash]
playlist = state['playlist'] playlist = state['playlist']
# Determine which provider to use # Determine which provider to use — respect user's configured primary source
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Initialize fallback client if needed # Initialize fallback client if needed
itunes_client_instance = None itunes_client_instance = None
@ -34786,6 +35104,11 @@ def _run_spotify_public_discovery_worker(url_hash):
'image_url': _image_url, 'image_url': _image_url,
'source': 'spotify' 'source': 'spotify'
} }
# Preserve track_number/disc_number from raw Spotify API data
if raw_track_data and raw_track_data.get('track_number'):
match_data['track_number'] = raw_track_data['track_number']
if raw_track_data and raw_track_data.get('disc_number'):
match_data['disc_number'] = raw_track_data['disc_number']
result['spotify_data'] = match_data result['spotify_data'] = match_data
result['match_data'] = match_data result['match_data'] = match_data
result['status'] = '✅ Found' result['status'] = '✅ Found'
@ -34899,6 +35222,11 @@ def convert_spotify_public_results_to_spotify_tracks(discovery_results):
'album': spotify_data['album'], 'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0) 'duration_ms': spotify_data.get('duration_ms', 0)
} }
# Preserve track_number/disc_number from discovery enrichment
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track) spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
track = { track = {
@ -35348,8 +35676,8 @@ def _run_youtube_discovery_worker(url_hash):
tracks = playlist['tracks'] tracks = playlist['tracks']
# Determine which provider to use (Spotify preferred, iTunes fallback) # Determine which provider to use (Spotify preferred, iTunes fallback)
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Get fallback client # Get fallback client
itunes_client = _get_metadata_fallback_client() itunes_client = _get_metadata_fallback_client()
@ -35662,8 +35990,8 @@ def _run_listenbrainz_discovery_worker(state_key):
tracks = playlist['tracks'] tracks = playlist['tracks']
# Determine which provider to use (Spotify preferred, iTunes fallback) # Determine which provider to use (Spotify preferred, iTunes fallback)
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Get fallback client # Get fallback client
itunes_client = _get_metadata_fallback_client() itunes_client = _get_metadata_fallback_client()
@ -36252,6 +36580,10 @@ def convert_youtube_results_to_spotify_tracks(discovery_results):
'album': spotify_data['album'], 'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0) 'duration_ms': spotify_data.get('duration_ms', 0)
} }
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track) spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
# Build from individual fields (automatic discovery format) # Build from individual fields (automatic discovery format)
@ -36591,12 +36923,21 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
# Update final state on completion # Update final state on completion
# Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO) # Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO)
# Exclude match_details — large, not needed for live status, saved to DB separately # Exclude match_details (large) but include a summary of unmatched tracks
result_dict = { result_dict = {
k: (v.isoformat() if hasattr(v, 'isoformat') else v) k: (v.isoformat() if hasattr(v, 'isoformat') else v)
for k, v in result.__dict__.items() for k, v in result.__dict__.items()
if k != 'match_details' if k != 'match_details'
} }
# Include unmatched track names so the frontend can show which tracks failed
match_details = getattr(result, 'match_details', None)
if match_details:
unmatched_summary = [
{'name': d.get('name', ''), 'artist': d.get('artist', ''), 'image_url': d.get('image_url', '')}
for d in match_details if d.get('status') == 'not_found'
]
if unmatched_summary:
result_dict['unmatched_tracks'] = unmatched_summary
with sync_lock: with sync_lock:
sync_states[playlist_id] = { sync_states[playlist_id] = {
"status": "finished", "status": "finished",
@ -40165,11 +40506,11 @@ def _get_active_discovery_source():
Determine which music source is active for discovery. Determine which music source is active for discovery.
Returns the user's configured primary metadata source. Returns the user's configured primary metadata source.
If the selected source requires auth and isn't available, falls back. If the selected source requires auth and isn't available, falls back.
NOTE: Thin wrapper canonical logic lives in core.metadata_service.get_primary_source().
""" """
source = _get_metadata_fallback_source() from core.metadata_service import get_primary_source
if source == 'spotify' and not (spotify_client and spotify_client.is_spotify_authenticated()): return get_primary_source()
return 'deezer'
return source
@app.route('/api/discover/hero', methods=['GET']) @app.route('/api/discover/hero', methods=['GET'])
@ -43921,6 +44262,10 @@ def convert_listenbrainz_results_to_spotify_tracks(discovery_results):
'album': spotify_data['album'], 'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0) 'duration_ms': spotify_data.get('duration_ms', 0)
} }
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track) spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
# Build from individual fields (automatic discovery format) # Build from individual fields (automatic discovery format)
@ -46135,8 +46480,8 @@ def _run_beatport_discovery_worker(url_hash):
tracks = chart['tracks'] tracks = chart['tracks']
# Determine which provider to use # Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated() discovery_source = _get_active_discovery_source()
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Initialize fallback client if needed # Initialize fallback client if needed
itunes_client_instance = None itunes_client_instance = None
@ -47025,8 +47370,8 @@ def prepare_mirrored_discovery(playlist_id):
}) })
# Determine current active metadata source for provider-mismatch detection # Determine current active metadata source for provider-mismatch detection
_use_spotify = spotify_client and spotify_client.is_spotify_authenticated() _current_provider = _get_active_discovery_source()
_current_provider = 'spotify' if _use_spotify else _get_metadata_fallback_source() _use_spotify = (_current_provider == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated()
# Check for cached discovery results in extra_data # Check for cached discovery results in extra_data
pre_discovered_results = [] pre_discovered_results = []
@ -47298,11 +47643,10 @@ def playlist_explorer_build_tree():
if not tracks: if not tracks:
return jsonify({"success": False, "error": "Playlist has no tracks"}), 400 return jsonify({"success": False, "error": "Playlist has no tracks"}), 400
# Determine active metadata source # Determine active metadata source — respect user's configured primary
spotify_available = spotify_client and spotify_client.is_spotify_authenticated() source_name = _get_active_discovery_source()
if spotify_available: if source_name == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
active_client = spotify_client active_client = spotify_client
source_name = 'spotify'
else: else:
active_client = _get_metadata_fallback_client() active_client = _get_metadata_fallback_client()
source_name = _get_metadata_fallback_source() source_name = _get_metadata_fallback_source()
@ -47642,13 +47986,18 @@ def convert_beatport_results_to_spotify_tracks(discovery_results):
# Convert from [{'name': 'Artist'}] to ['Artist'] # Convert from [{'name': 'Artist'}] to ['Artist']
artists = [artist['name'] for artist in artists] artists = [artist['name'] for artist in artists]
spotify_tracks.append({ track = {
'id': spotify_data['id'], 'id': spotify_data['id'],
'name': spotify_data['name'], 'name': spotify_data['name'],
'artists': artists, 'artists': artists,
'album': spotify_data['album'], 'album': spotify_data['album'],
'source': 'beatport' 'source': 'beatport'
}) }
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found': elif result.get('spotify_track') and result.get('status_class') == 'found':
# Build from individual fields (automatic discovery format) # Build from individual fields (automatic discovery format)
album_val = result.get('spotify_album', '') album_val = result.get('spotify_album', '')

View file

@ -4595,6 +4595,14 @@
</div> </div>
</div> </div>
<div class="form-group">
<label>Music Videos Dir:</label>
<div class="path-input-group">
<input type="text" id="music-videos-path" placeholder="./MusicVideos" readonly>
<button class="browse-button locked" onclick="togglePathLock('music-videos', this)">Unlock</button>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Download Source:</label> <label>Download Source:</label>
<select id="download-source-mode" class="form-select" <select id="download-source-mode" class="form-select"
@ -5640,6 +5648,13 @@
</label> </label>
<small class="settings-hint">Dashboard header buttons animate as floating orbs. Hover the header to expand. Desktop only.</small> <small class="settings-hint">Dashboard header buttons animate as floating orbs. Hover the header to expand. Desktop only.</small>
</div> </div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="reduce-effects-enabled">
Reduce Visual Effects
</label>
<small class="settings-hint">Disables backdrop blur, animations, transitions, and shadows. Significantly reduces GPU/CPU usage on low-end devices.</small>
</div>
</div> </div>
<!-- Database Settings --> <!-- Database Settings -->

View file

@ -892,10 +892,30 @@ function initAccentColorListeners() {
applyWorkerOrbsSetting(workerOrbsCheckbox.checked); applyWorkerOrbsSetting(workerOrbsCheckbox.checked);
}); });
} }
// Reduce effects toggle — apply immediately on change
const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceEffectsCheckbox) {
reduceEffectsCheckbox.addEventListener('change', () => {
applyReduceEffects(reduceEffectsCheckbox.checked);
});
}
} }
// Bootstrap accent from localStorage instantly (prevents default-color flash) function applyReduceEffects(enabled) {
if (enabled) {
document.body.classList.add('reduce-effects');
} else {
document.body.classList.remove('reduce-effects');
}
localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0');
}
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
(function() { (function() {
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
document.body.classList.add('reduce-effects');
}
const saved = localStorage.getItem('soulsync-accent'); const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved); if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage // Bootstrap particles setting from localStorage
@ -5879,6 +5899,7 @@ async function loadSettingsData() {
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
document.getElementById('staging-path').value = settings.import?.staging_path || './Staging'; document.getElementById('staging-path').value = settings.import?.staging_path || './Staging';
document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos';
// Populate Download Source settings // Populate Download Source settings
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
@ -6024,6 +6045,12 @@ async function loadSettingsData() {
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
applyWorkerOrbsSetting(workerOrbsEnabled); applyWorkerOrbsSetting(workerOrbsEnabled);
// Reduce effects toggle
const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false
const reduceCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
applyReduceEffects(reduceEffects);
// Populate Logging information (read-only) // Populate Logging information (read-only)
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
@ -7127,7 +7154,8 @@ async function saveSettings(quiet = false) {
allow_explicit: document.getElementById('allow-explicit').checked allow_explicit: document.getElementById('allow-explicit').checked
}, },
library: { library: {
music_paths: collectMusicPaths() music_paths: collectMusicPaths(),
music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos'
}, },
import: { import: {
replace_lower_quality: document.getElementById('import-replace-lower-quality').checked replace_lower_quality: document.getElementById('import-replace-lower-quality').checked
@ -7154,7 +7182,8 @@ async function saveSettings(quiet = false) {
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954', accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
particles_enabled: document.getElementById('particles-enabled')?.checked !== false, particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false,
reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true
}, },
youtube: { youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value, cookies_browser: document.getElementById('youtube-cookies-browser').value,
@ -8177,7 +8206,8 @@ async function logoutQobuz() {
const PATH_INPUT_IDS = { const PATH_INPUT_IDS = {
download: 'download-path', download: 'download-path',
transfer: 'transfer-path', transfer: 'transfer-path',
staging: 'staging-path' staging: 'staging-path',
'music-videos': 'music-videos-path'
}; };
function togglePathLock(pathType, btn) { function togglePathLock(pathType, btn) {
@ -8300,6 +8330,7 @@ function initializeSearchModeToggle() {
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
}; };
// Live search with debouncing // Live search with debouncing
@ -8424,7 +8455,7 @@ function initializeSearchModeToggle() {
// Fire ALL source fetches immediately in parallel with the primary endpoint. // Fire ALL source fetches immediately in parallel with the primary endpoint.
// Don't guess which is primary — the main endpoint response will tell us. // Don't guess which is primary — the main endpoint response will tell us.
// If an alternate duplicates the primary, it just overwrites with same data. // If an alternate duplicates the primary, it just overwrites with same data.
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) { for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) {
_fetchAlternateSource(srcName, query); _fetchAlternateSource(srcName, query);
} }
@ -8482,6 +8513,9 @@ function initializeSearchModeToggle() {
} }
function renderDropdownResults(data) { function renderDropdownResults(data) {
// Music Videos tab — don't render regular sections
if (_activeSearchSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary) // Determine source badge from active tab (not just primary)
const displaySource = _activeSearchSource || data.metadata_source || 'spotify'; const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify; const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
@ -8696,7 +8730,8 @@ function initializeSearchModeToggle() {
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
if (!_enhancedSearchData) return; if (!_enhancedSearchData) return;
if (!_enhancedSearchData.sources[sourceName]) { if (!_enhancedSearchData.sources[sourceName]) {
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) }; const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
} }
const sourceData = _enhancedSearchData.sources[sourceName]; const sourceData = _enhancedSearchData.sources[sourceName];
@ -8720,6 +8755,7 @@ function initializeSearchModeToggle() {
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
else if (chunk.type === 'done') { delete sourceData._loading; break; } else if (chunk.type === 'done') { delete sourceData._loading; break; }
// Re-render tabs + content if this is the active source // Re-render tabs + content if this is the active source
@ -8767,7 +8803,9 @@ function initializeSearchModeToggle() {
tabBar.innerHTML = ordered.map(name => { tabBar.innerHTML = ordered.map(name => {
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
const src = sources[name] || {}; const src = sources[name] || {};
const count = (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); const count = name === 'youtube_videos'
? (src.videos?.length || 0)
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
const isActive = name === _activeSearchSource; const isActive = name === _activeSearchSource;
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}" return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')" onclick="window._switchEnhSourceTab('${name}')"
@ -8792,6 +8830,27 @@ function initializeSearchModeToggle() {
tab.classList.toggle('active', tab.dataset.source === sourceName); tab.classList.toggle('active', tab.dataset.source === sourceName);
}); });
// Music Videos tab — render video cards instead of regular sections
if (sourceName === 'youtube_videos') {
// Hide ALL regular sections including wrappers
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
});
// Hide the artists wrapper div too
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = 'none';
_renderVideoResults(src.videos || []);
resultsContainer.classList.remove('hidden');
return;
}
// Hide videos section and restore regular layout when switching to a metadata tab
const videosSec = document.getElementById('enh-videos-section');
if (videosSec) videosSec.classList.add('hidden');
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = '';
// Build data in the shape renderDropdownResults expects // Build data in the shape renderDropdownResults expects
const viewData = { const viewData = {
db_artists: _enhancedSearchData.db_artists, db_artists: _enhancedSearchData.db_artists,
@ -8824,6 +8883,70 @@ function initializeSearchModeToggle() {
} }
}; };
function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section');
if (!section) {
// Create the section dynamically if it doesn't exist
const container = document.getElementById('enhanced-results-container');
if (!container) return;
section = document.createElement('div');
section.id = 'enh-videos-section';
section.className = 'enh-dropdown-section';
section.innerHTML = `
<div class="enh-section-header">
<span class="enh-section-icon">🎬</span>
<h4 class="enh-section-title">Music Videos</h4>
<span class="enh-section-count" id="enh-videos-count">0</span>
</div>
<div class="enh-video-grid" id="enh-videos-list"></div>
`;
container.appendChild(section);
}
section.classList.remove('hidden');
const countEl = document.getElementById('enh-videos-count');
const listEl = document.getElementById('enh-videos-list');
if (countEl) countEl.textContent = videos.length;
if (!videos.length) {
listEl.innerHTML = '<div class="enh-empty-state">No music videos found</div>';
return;
}
listEl.innerHTML = videos.map(v => {
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count ? _formatViewCount(v.view_count) : '';
return `
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${JSON.stringify(v).replace(/"/g, '&quot;')})">
<div class="enh-video-thumb">
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden">
<svg viewBox="0 0 36 36">
<circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/>
<circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/>
</svg>
</div>
<div class="enh-video-done hidden"></div>
<div class="enh-video-error hidden"></div>
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
</div>
<div class="enh-video-info">
<div class="enh-video-title" title="${v.title.replace(/"/g, '&quot;')}">${v.title}</div>
<div class="enh-video-channel">${v.channel}${views ? ` · ${views} views` : ''}</div>
</div>
</div>
`;
}).join('');
}
function _formatViewCount(count) {
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return String(count);
}
// Lazy load artist images for enhanced search results // Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() { async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [ const artistLists = [
@ -16269,9 +16392,13 @@ function updateCardToDefault(playlistId, finalState = null) {
// Check if any tracks were added to wishlist // Check if any tracks were added to wishlist
const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0; const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0;
const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || [];
const playlistName = card.querySelector('.playlist-card-name').textContent; const playlistName = card.querySelector('.playlist-card-name').textContent;
if (wishlistCount > 0) { if (wishlistCount > 0 && unmatchedTracks.length > 0) {
const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', ');
showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning');
} else if (wishlistCount > 0) {
showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success'); showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success');
} else { } else {
showToast(`Sync complete for "${playlistName}"`, 'success'); showToast(`Sync complete for "${playlistName}"`, 'success');
@ -17321,6 +17448,71 @@ function _notifTimeAgo(ts) {
} }
// ================================================================================== // ==================================================================================
// Music video download handler — defined at top level so both enhanced and global search can use it
function _downloadMusicVideo(cardEl, video) {
if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return;
cardEl.classList.add('downloading');
cardEl.onclick = null;
const playBtn = cardEl.querySelector('.enh-video-play');
const progressRing = cardEl.querySelector('.enh-video-progress-ring');
const progressBar = cardEl.querySelector('.enh-video-progress-bar');
const doneIcon = cardEl.querySelector('.enh-video-done');
const errorIcon = cardEl.querySelector('.enh-video-error');
if (playBtn) playBtn.classList.add('hidden');
if (progressRing) progressRing.classList.remove('hidden');
fetch('/api/music-video/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }),
}).then(res => {
if (!res.ok) throw new Error('Download request failed');
const circumference = 97.4;
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/music-video/status/${video.video_id}`);
const status = await statusRes.json();
if (progressBar && status.progress > 0) {
progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference;
}
if (status.status === 'completed') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('completed');
if (progressRing) progressRing.classList.add('hidden');
if (doneIcon) doneIcon.classList.remove('hidden');
} else if (status.status === 'error') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('errored');
if (progressRing) progressRing.classList.add('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
}
} catch (e) {}
}, 500);
}).catch(e => {
cardEl.classList.remove('downloading');
if (progressRing) progressRing.classList.add('hidden');
if (playBtn) playBtn.classList.remove('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
});
}
// Global search video click — decodes base64 video data and delegates to _downloadMusicVideo
function _gsClickVideo(cardEl) {
try {
const encoded = cardEl.dataset.video;
const video = JSON.parse(decodeURIComponent(escape(atob(encoded))));
_downloadMusicVideo(cardEl, video);
} catch (e) {
console.error('Failed to parse video data:', e);
}
}
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere // GLOBAL SEARCH BAR — Spotlight-style search from anywhere
// ================================================================================== // ==================================================================================
@ -17509,7 +17701,8 @@ async function _gsFetchSourceStream(src, query) {
if (!res.ok) return; if (!res.ok) return;
if (!_gsState.sources[src]) { if (!_gsState.sources[src]) {
_gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) }; const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
} }
const sourceData = _gsState.sources[src]; const sourceData = _gsState.sources[src];
@ -17532,6 +17725,7 @@ async function _gsFetchSourceStream(src, query) {
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
if (chunk.type === 'done') delete sourceData._loading; if (chunk.type === 'done') delete sourceData._loading;
_gsRenderTabs(); _gsRenderTabs();
// Re-render content if this is the active source tab // Re-render content if this is the active source tab
@ -17551,6 +17745,43 @@ function _gsRender(data) {
const results = document.getElementById('gsearch-results'); const results = document.getElementById('gsearch-results');
if (!results) return; if (!results) return;
// Music Videos tab — render video grid instead of regular results
if (_gsState.activeSource === 'youtube_videos') {
const src = _gsState.sources['youtube_videos'] || {};
const videos = src.videos || [];
const isLoading = src._loading && src._loading.size > 0;
let h = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
h += '<div class="gsearch-results-body">';
if (isLoading) {
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
} else if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
} else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">';
h += videos.map(v => {
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || '');
const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v))));
return `<div class="enh-video-card" data-video-id="${v.video_id}" data-video="${vJson}" onclick="_gsClickVideo(this)">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden"><svg viewBox="0 0 36 36"><circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/></svg></div>
<div class="enh-video-done hidden"></div><div class="enh-video-error hidden"></div>
${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
</div>`;
}).join('');
h += '</div>';
}
h += '</div>';
results.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
return;
}
const src = _gsState.sources[_gsState.activeSource] || {}; const src = _gsState.sources[_gsState.activeSource] || {};
const loading = src._loading || new Set(); const loading = src._loading || new Set();
const dbArtists = data?.db_artists || []; const dbArtists = data?.db_artists || [];
@ -17568,7 +17799,7 @@ function _gsRender(data) {
return; return;
} }
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
let h = ''; let h = '';
@ -17665,11 +17896,13 @@ function _gsRenderTabs() {
if (!el) return; if (!el) return;
const sources = Object.keys(_gsState.sources); const sources = Object.keys(_gsState.sources);
if (sources.length < 2) { el.style.display = 'none'; return; } if (sources.length < 2) { el.style.display = 'none'; return; }
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
el.style.display = 'flex'; el.style.display = 'flex';
el.innerHTML = sources.map(s => { el.innerHTML = sources.map(s => {
const d = _gsState.sources[s]; const d = _gsState.sources[s];
const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); const c = s === 'youtube_videos'
? (d.videos?.length || 0)
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`; return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
}).join(''); }).join('');
} }

View file

@ -32887,6 +32887,188 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; } .enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; } .enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; }
.enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; } .enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; }
.enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; }
/* Music Video Grid */
.enh-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
padding: 4px 0;
}
.enh-video-card {
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.enh-video-card:hover {
transform: translateY(-3px);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
.enh-video-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.enh-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.enh-video-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.enh-video-card:hover .enh-video-play {
opacity: 1;
}
.enh-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.3px;
}
.enh-video-info {
padding: 10px 12px;
}
.enh-video-title {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.enh-video-channel {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
/* Video download states */
.enh-video-card.downloading {
pointer-events: none;
}
.enh-video-card.downloading .enh-video-thumb::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1;
}
.enh-video-card.completed .enh-video-thumb::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.enh-video-card.errored .enh-video-thumb::after {
content: '';
position: absolute;
inset: 0;
background: rgba(180, 0, 0, 0.3);
}
.enh-video-progress-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 52px;
height: 52px;
z-index: 2;
filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.5));
}
.enh-video-progress-ring svg {
width: 100%;
height: 100%;
}
.enh-video-progress-bar {
transition: stroke-dashoffset 0.3s ease;
}
.enh-video-done, .enh-video-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
z-index: 2;
}
.enh-video-done {
background: rgba(29, 185, 84, 0.85);
color: #fff;
}
.enh-video-error {
background: rgba(220, 50, 50, 0.85);
color: #fff;
cursor: pointer;
}
.enh-empty-state {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
@media (max-width: 600px) {
.enh-video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
}
.enh-dropdown-section { .enh-dropdown-section {
margin-bottom: 24px; margin-bottom: 24px;
@ -54967,3 +55149,16 @@ tr:hover .enhanced-track-actions-group { opacity: 1; }
font-size: 12px; flex-shrink: 0; transition: all 0.15s; font-size: 12px; flex-shrink: 0; transition: all 0.15s;
} }
.blacklist-entry-remove:hover { background: rgba(239, 83, 80, 0.12); color: #ef5350; } .blacklist-entry-remove:hover { background: rgba(239, 83, 80, 0.12); color: #ef5350; }
/* ── Reduce Visual Effects ── Disables GPU-heavy properties globally */
body.reduce-effects *,
body.reduce-effects *::before,
body.reduce-effects *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}