diff --git a/Dockerfile b/Dockerfile index a8c246b9..97437292 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ COPY . . # Create necessary directories with proper permissions # 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 # Create defaults directory and copy template files @@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \ # Create volume mount points # 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 entrypoint.sh /entrypoint.sh diff --git a/api/search.py b/api/search.py index 321f144d..48a237c7 100644 --- a/api/search.py +++ b/api/search.py @@ -42,16 +42,17 @@ def register_routes(bp): pass 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) if results: tracks = [_serialize_track(t) for t in results] return api_success({"tracks": tracks, "source": "spotify"}) if source in ("itunes", "deezer", "auto"): - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_tracks(query, limit=limit) if results: tracks = [_serialize_track(t) for t in results] @@ -78,7 +79,9 @@ def register_routes(bp): try: ctx = current_app.soulsync 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) if results: return api_success({ @@ -86,9 +89,8 @@ def register_routes(bp): "source": "spotify", }) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_albums(query, limit=limit) return api_success({ "albums": [_serialize_album(a) for a in results] if results else [], @@ -114,7 +116,9 @@ def register_routes(bp): try: ctx = current_app.soulsync 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) if results: return api_success({ @@ -122,9 +126,8 @@ def register_routes(bp): "source": "spotify", }) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + fallback = get_primary_client() + fallback_source = get_primary_source() results = fallback.search_artists(query, limit=limit) return api_success({ "artists": [_serialize_artist(a) for a in results] if results else [], diff --git a/config/settings.py b/config/settings.py index 85272c93..af1844b3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -461,7 +461,8 @@ class ConfigManager: "poll_interval": 30 }, "library": { - "music_paths": [] + "music_paths": [], + "music_videos_path": "./MusicVideos" }, "scripts": { "path": "./scripts", diff --git a/core/deezer_client.py b/core/deezer_client.py index f6981240..5b89f868 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -392,9 +392,11 @@ class DeezerClient: cache = get_metadata_cache() cached = cache.get_entity('deezer', 'track', str(track_id)) if cached and cached.get('title'): - # Search results are cached with minimal data (no release_date, track_position). - # Only use cache if it has fields that the /track/{id} endpoint provides. - if 'release_date' in cached or 'track_position' in cached or 'isrc' in cached: + # Search results are cached with minimal data (no track_position). + # Only use cache if it has track_position — the key field from /track/{id}. + # 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) # Otherwise fall through to fetch full data from API @@ -634,7 +636,17 @@ class DeezerClient: albums.append(album) 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: cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True) diff --git a/core/matching_engine.py b/core/matching_engine.py index ae5bb066..9fd4f65f 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -602,23 +602,42 @@ class MusicMatchingEngine: # No word boundary match - rely on similarity ratio only title_score = title_ratio - # 2. Artist Score: Keep substring matching for artists (they're more unique) - # But add similarity-based fallback for better matching + # 2. Artist Score: Word-boundary matching for artists to prevent false positives + # like "muse" matching "museum" or "art" matching "heart". + # Falls back to similarity matching for misspellings/variations. artist_score = 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: - # Skip containment for very short names (≤2 chars) — "b" matches everything - if artist and len(artist) > 2 and artist in slskd_filename_norm: - artist_score = 1.0 # Perfect match if any artist is found - break - elif artist and len(artist) <= 2 and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm): + if not artist: + continue + # 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 + # 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 break else: - # Try similarity matching as fallback for misspellings/variations - artist_ratio = SequenceMatcher(None, artist, slskd_filename_norm).ratio() - best_artist_similarity = max(best_artist_similarity, artist_ratio) + # Try similarity matching per path segment for misspellings/variations. + # Comparing against the full filename dilutes the score because the artist + # 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 artist_score == 0.0 and best_artist_similarity > 0: @@ -672,13 +691,35 @@ class MusicMatchingEngine: ) 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 --- if is_youtube: - # For YouTube, rely more on Title and Duration since Artist is often missing from video titles - # and the search query already filtered by artist to some extent. - # New weights: Title 70%, Artist 10%, Duration 20% - final_confidence = (title_score * 0.70) + (artist_score * 0.10) + (duration_score * 0.20) + # For YouTube, artist gets more weight than before to reduce wrong-uploader matches. + # Previous: Title 70%, Artist 10%, Duration 20% — artist was nearly irrelevant. + # New: Title 60%, Artist 20%, Duration 20% + final_confidence = (title_score * 0.60) + (artist_score * 0.20) + (duration_score * 0.20) else: # Standard weights for Soulseek (Artist is critical for correctness) # Rebalanced weights: Artist matching is now more important to prevent false positives diff --git a/core/metadata_service.py b/core/metadata_service.py index 2721a13d..9173f809 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -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 -fallback source (iTunes or Deezer) when not. -Provides unified interface for all metadata operations. +ALL metadata source decisions flow through this module. Other files import +get_primary_source() and get_primary_client() instead of reimplementing +the logic. This prevents bugs where different files have different defaults +or auth checks. """ from typing import List, Optional, Dict, Any, Literal @@ -16,37 +17,106 @@ logger = get_logger("metadata_service") 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: 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: - 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(): - """Create the configured fallback metadata client.""" - source = _get_configured_fallback_source() +def get_primary_client(): + """Get the client object for the user's configured primary metadata 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': from core.deezer_client import 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': try: - from core.hydrabase_client import HydrabaseClient - # Hydrabase client is managed globally — try to import the running instance import importlib - ws_module = importlib.import_module('web_server') - client = getattr(ws_module, 'hydrabase_client', None) + ws = importlib.import_module('web_server') + client = getattr(ws, 'hydrabase_client', None) if client and client.is_connected(): return client except Exception: pass - # Hydrabase not available — fall back to iTunes return iTunesClient() + + # Default: iTunes 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: """ Unified metadata service that seamlessly switches between Spotify and @@ -70,8 +140,8 @@ class MetadataService: """ self.preferred_provider = preferred_provider self.spotify = SpotifyClient() - self._fallback_source = _get_configured_fallback_source() - self.itunes = _create_fallback_client() # May be iTunesClient or DeezerClient + self._fallback_source = get_primary_source() + self.itunes = get_primary_client() # May be iTunesClient or DeezerClient self._log_initialization() @@ -94,10 +164,8 @@ class MetadataService: return "spotify" elif self.preferred_provider == "itunes": return self._fallback_source - else: # auto - # Use is_spotify_authenticated() to check actual Spotify auth status - # (is_authenticated() always returns True due to fallback) - return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source + else: # auto — use the centralized source selection + return get_primary_source() def _get_client(self): """Get the appropriate client based on provider selection""" @@ -244,10 +312,10 @@ class MetadataService: logger.info("Reloading metadata service configuration") self.spotify.reload_config() # 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: self._fallback_source = new_source - self.itunes = _create_fallback_client() + self.itunes = get_primary_client() elif hasattr(self.itunes, 'reload_config'): self.itunes.reload_config() self._log_initialization() diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 6fd3c8cd..b4c5aed7 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -101,18 +101,9 @@ class PersonalizedPlaylistsService: self.spotify_client = spotify_client def _get_active_source(self) -> str: - """ - Determine which music source is active for discovery. - Returns 'spotify' if Spotify is authenticated, otherwise the configured fallback ('itunes' or 'deezer'). - """ - 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' + """Determine which music source is active — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _build_track_dict(self, row, source: str) -> Dict: """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)}") return {'tracks': [], 'error': 'Must provide 1-5 seed artists'} - use_spotify = self.spotify_client and self.spotify_client.sp - active_source = 'spotify' if use_spotify else self._get_active_source() + active_source = 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})") # Step 1: Get similar artists for each seed @@ -914,8 +905,8 @@ class PersonalizedPlaylistsService: seen_artist_ids.add(artist_id) if len(all_similar_artists) >= 25: break - elif use_spotify: - # Fallback: fetch related artists from Spotify API + elif self.spotify_client and self.spotify_client.sp: + # 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") try: 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}") continue else: - from core.metadata_service import _create_fallback_client - itunes = _create_fallback_client() + from core.metadata_service import get_primary_client + itunes = get_primary_client() for artist in artists_for_albums: try: albums = itunes.get_artist_albums(artist['id'], limit=10) @@ -1019,8 +1010,8 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting tracks from album: {e}") continue else: - from core.metadata_service import _create_fallback_client - itunes = _create_fallback_client() + from core.metadata_service import get_primary_client + itunes = get_primary_client() for album in selected_albums: try: album_data = itunes.get_album(album.id, include_tracks=True) diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 4760e35d..159dc7bc 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -42,6 +42,7 @@ _JOB_MODULES = [ 'core.repair_jobs.lossy_converter', 'core.repair_jobs.album_tag_consistency', 'core.repair_jobs.live_commentary_cleaner', + 'core.repair_jobs.unknown_artist_fixer', ] diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index e64465d5..8c6debe0 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -785,8 +785,8 @@ class LibraryReorganizeJob(RepairJob): if not search_client: # Try fallback (iTunes/Deezer) try: - from core.metadata_service import _create_fallback_client - search_client = _create_fallback_client() + from core.metadata_service import get_primary_client + search_client = get_primary_client() source_name = 'fallback' except Exception: pass diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 875b7665..b9f5371b 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -61,8 +61,9 @@ class OrphanFileDetectorJob(RepairJob): cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") for row in cursor.fetchall(): parts = row[0].replace('\\', '/').split('/') - # Store last 1, 2, and 3 path components as lowercase suffixes - for depth in range(1, min(4, len(parts) + 1)): + # Store last 1-4 path components as lowercase suffixes. + # Depth 4 covers Genre/Artist/Album/track.flac scenarios. + for depth in range(1, min(5, len(parts) + 1)): suffix = '/'.join(parts[-depth:]).lower() known_suffixes.add(suffix) @@ -127,7 +128,7 @@ class OrphanFileDetectorJob(RepairJob): # Check if this file matches any known DB path via suffix matching fpath_parts = fpath.replace('\\', '/').split('/') 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() if suffix in known_suffixes: is_known = True @@ -161,6 +162,33 @@ class OrphanFileDetectorJob(RepairJob): except Exception: 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: orphan_files.append(fpath) diff --git a/core/repair_jobs/unknown_artist_fixer.py b/core/repair_jobs/unknown_artist_fixer.py new file mode 100644 index 00000000..353039fa --- /dev/null +++ b/core/repair_jobs/unknown_artist_fixer.py @@ -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) diff --git a/core/repair_worker.py b/core/repair_worker.py index 2b75f763..ff3bd531 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -161,8 +161,8 @@ class RepairWorker: def itunes_client(self): if self._itunes_client is None: try: - from core.metadata_service import _create_fallback_client - self._itunes_client = _create_fallback_client() + from core.metadata_service import get_primary_client + self._itunes_client = get_primary_client() except Exception as e: logger.error("Failed to initialize fallback metadata client: %s", e) return self._itunes_client @@ -632,11 +632,11 @@ class RepairWorker: conn = self.db._get_connection() 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(""" SELECT id FROM repair_findings 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)) LIMIT 1 """, (job_id, finding_type, entity_type, entity_id, file_path)) @@ -816,6 +816,7 @@ class RepairWorker: 'path_mismatch': self._fix_path_mismatch, 'missing_lossy_copy': self._fix_missing_lossy_copy, 'unwanted_content': self._fix_unwanted_content, + 'unknown_artist': self._fix_unknown_artist, } handler = handlers.get(finding_type) if not handler: @@ -1382,6 +1383,109 @@ class RepairWorker: msg += ' (file deleted)' 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): """Remove the mismatched MusicBrainz recording ID from the audio file.""" if not file_path: diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index a3295d4e..67859607 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -96,14 +96,9 @@ class SeasonalDiscoveryService: self._ensure_database_schema() def _get_source(self): - """Determine active music source (matches _get_active_discovery_source in web_server)""" - if self.spotify_client and self.spotify_client.is_spotify_authenticated(): - return 'spotify' - try: - from core.metadata_service import _get_configured_fallback_source - return _get_configured_fallback_source() - except Exception: - return 'itunes' + """Determine active music source — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _ensure_database_schema(self): """Create seasonal content tables if they don't exist""" @@ -447,14 +442,14 @@ class SeasonalDiscoveryService: seasonal_albums = [] 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 sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists))) - from core.metadata_service import _create_fallback_client, _get_configured_fallback_source - fallback_client = _create_fallback_client() - fallback_source = _get_configured_fallback_source() + from core.metadata_service import get_primary_client, get_primary_source + fallback_client = get_primary_client() + fallback_source = get_primary_source() for artist in sampled_artists: try: @@ -512,7 +507,7 @@ class SeasonalDiscoveryService: config = SEASONAL_CONFIG[season_key] keywords = config['keywords'] 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 = [] seen_album_ids = set() @@ -556,8 +551,8 @@ class SeasonalDiscoveryService: continue else: # Fallback metadata source (iTunes or Deezer) - from core.metadata_service import _create_fallback_client - fallback_client = _create_fallback_client() + from core.metadata_service import get_primary_client + fallback_client = get_primary_client() for keyword in search_keywords: try: @@ -772,10 +767,10 @@ class SeasonalDiscoveryService: # Get tracks from seasonal albums (filtered by 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: - from core.metadata_service import _create_fallback_client - fallback_client = _create_fallback_client() + from core.metadata_service import get_primary_client + fallback_client = get_primary_client() for album in seasonal_albums: try: diff --git a/core/spotify_client.py b/core/spotify_client.py index 8462417c..2017dfa4 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -506,11 +506,11 @@ class SpotifyClient: @property def _fallback_source(self) -> str: - """Get configured metadata fallback source ('itunes', 'deezer', or 'discogs')""" + """Get configured primary metadata source for internal fallback routing.""" try: - return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' except Exception: - return 'itunes' + return 'deezer' @property def _fallback(self): diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 89f744f1..bb57e6e5 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -30,18 +30,9 @@ ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff def _get_fallback_metadata_client(): - """Get the configured metadata fallback client (iTunes or Deezer).""" - try: - from config.settings import config_manager - 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' + """Get the configured metadata client — delegates to centralized metadata_service.""" + from core.metadata_service import get_primary_source, get_primary_client + return get_primary_client(), get_primary_source() def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs): diff --git a/core/youtube_client.py b/core/youtube_client.py index b5a423f6..0f0e6fe0 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -558,9 +558,79 @@ class YouTubeClient: thumbnail = thumbs[-1].get('url') track_result.thumbnail = thumbnail - + 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]]: """ Search YouTube for tracks matching the query (async, Soulseek-compatible interface). @@ -1000,6 +1070,65 @@ class YouTubeClient: traceback.print_exc() 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]: """ Get all active downloads (matches Soulseek interface). diff --git a/database/music_database.py b/database/music_database.py index 34e51570..fe6cbb2f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -551,9 +551,43 @@ class MusicDatabase: except Exception: 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() logger.info("Database initialized successfully") - + except Exception as e: logger.error(f"Error initializing database: {e}") raise diff --git a/docker-compose.yml b/docker-compose.yml index 15f0a61d..d51b278c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - ./logs:/app/logs - ./downloads:/app/downloads - ./Staging:/app/Staging + - ./MusicVideos:/app/MusicVideos - ./scripts:/app/scripts # Use named volume for database persistence (separate from host database) # NOTE: Changed from /app/database to /app/data to avoid overwriting Python package diff --git a/web_server.py b/web_server.py index 3bbb6be2..0b92400c 100644 --- a/web_server.py +++ b/web_server.py @@ -855,13 +855,18 @@ def _register_automation_handlers(): md = extra['matched_data'] album_raw = md.get('album', '') album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} - tracks_json.append({ + _track_entry = { 'name': md.get('name', ''), 'artists': md.get('artists', [{'name': t.get('artist_name', '')}]), 'album': album_obj, 'duration_ms': md.get('duration_ms', 0), '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: # NOT discovered — try to include using available metadata so the # 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: 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: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -7868,6 +7873,8 @@ def enhanced_search(): alternate_sources.append('discogs') if primary_source != 'hydrabase' and hydrabase_available: 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, " 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. 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 data = request.get_json() @@ -7962,6 +7969,37 @@ def enhanced_search_source(source_name): if not query: 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: client = None 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) 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/', 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']) def start_download(): """Simple download route""" @@ -21116,6 +21286,32 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "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", "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 = {} 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 + # 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)) if album_id: 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] wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)} 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', '?')}'") @@ -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. # All tracks from the same album get the same artist context, # 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, {}) if not artist_ctx or not artist_ctx.get('name'): - # Fallback: per-track resolution (shouldn't happen, but safety) - artist_ctx = {'name': track_info.get('artist', 'Unknown Artist')} + # Fallback: per-track resolution from artists array + _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 # 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 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 - # produced incomplete data - if not has_explicit_context and isinstance(detailed_track.get('album'), dict): + # Backfill album metadata from detailed track when context + # has incomplete data (missing release_date, total_tracks, etc.) + if isinstance(detailed_track.get('album'), dict): dt_album = detailed_track['album'] if not spotify_album_context.get('release_date') and dt_album.get('release_date'): spotify_album_context['release_date'] = 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'] if not spotify_album_context.get('total_tracks') and dt_album.get('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 dt_album.get('name'): - spotify_album_context['name'] = dt_album['name'] + if not spotify_album_context.get('id') and dt_album.get('id'): + spotify_album_context['id'] = dt_album['id'] + 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: print(f"⚠️ [Context] API track details failed: {e}") @@ -32386,8 +32604,8 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): _ew_state = {} try: _ew_state = _pause_enrichment_workers('mirrored playlist discovery') - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() itunes_client_instance = None if not use_spotify: @@ -32441,8 +32659,20 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): except (json.JSONDecodeError, TypeError): pass if existing_extra.get('discovered'): - pl_skipped += 1 - total_skipped += 1 + # 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 + total_skipped += 1 + else: + # Incomplete discovery — re-discover to get full metadata + undiscovered_tracks.append(track) else: 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 ''} if match_image: 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 = { 'id': best_match.id if hasattr(best_match, 'id') 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, 'source': discovery_source, } + if track_number: + matched_data['track_number'] = track_number + if disc_number: + matched_data['disc_number'] = disc_number extra_data = { 'discovered': True, @@ -32799,9 +33062,9 @@ def _run_tidal_discovery_worker(playlist_id): state = tidal_discovery_states[playlist_id] playlist = state['playlist'] - # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + # Determine which provider to use — respect user's configured primary source + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -32900,6 +33163,11 @@ def _run_tidal_discovery_worker(playlist_id): 'image_url': _image_url, '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['match_data'] = match_data 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 '' 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. + # The Track dataclass strips this data — the API has it + album_obj = { + 'name': album_name, + 'album_type': 'album', + 'release_date': getattr(best_match, 'release_date', '') or '', + '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': { - 'name': album_name, - 'album_type': 'album', - 'release_date': getattr(best_match, 'release_date', '') or '', - 'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else [] - }, + 'album': album_obj, 'duration_ms': duration_ms, 'source': _get_metadata_fallback_source(), '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: print(f"❌ No suitable Tidal match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})") return None @@ -33152,6 +33456,10 @@ def convert_tidal_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], '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) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) @@ -33326,11 +33634,12 @@ def _get_deezer_client(): def _get_metadata_fallback_source(): """Get the configured primary metadata source. - Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.""" - try: - return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - except Exception: - return 'deezer' + Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'. + + NOTE: This is a thin wrapper — canonical logic lives in core.metadata_service.get_primary_source(). + Kept as a local function because 70+ callers reference it by name.""" + from core.metadata_service import get_primary_source + return get_primary_source() def _get_metadata_fallback_client(): """Get the active metadata client based on settings. @@ -33818,8 +34127,8 @@ def _run_deezer_discovery_worker(playlist_id): playlist = state['playlist'] # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -33956,6 +34265,11 @@ def _run_deezer_discovery_worker(playlist_id): 'image_url': _image_url, '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['match_data'] = match_data result['status'] = '✅ Found' @@ -34072,6 +34386,10 @@ def convert_deezer_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], '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) elif result.get('spotify_track') and result.get('status_class') == 'found': track = { @@ -34636,9 +34954,9 @@ def _run_spotify_public_discovery_worker(url_hash): state = spotify_public_discovery_states[url_hash] playlist = state['playlist'] - # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + # Determine which provider to use — respect user's configured primary source + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -34786,6 +35104,11 @@ def _run_spotify_public_discovery_worker(url_hash): 'image_url': _image_url, '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['match_data'] = match_data result['status'] = '✅ Found' @@ -34899,6 +35222,11 @@ def convert_spotify_public_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], '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) elif result.get('spotify_track') and result.get('status_class') == 'found': track = { @@ -35348,8 +35676,8 @@ def _run_youtube_discovery_worker(url_hash): tracks = playlist['tracks'] # Determine which provider to use (Spotify preferred, iTunes fallback) - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Get fallback client itunes_client = _get_metadata_fallback_client() @@ -35662,8 +35990,8 @@ def _run_listenbrainz_discovery_worker(state_key): tracks = playlist['tracks'] # Determine which provider to use (Spotify preferred, iTunes fallback) - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Get 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'], '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) elif result.get('spotify_track') and result.get('status_class') == 'found': # 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 # 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 = { k: (v.isoformat() if hasattr(v, 'isoformat') else v) for k, v in result.__dict__.items() 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: sync_states[playlist_id] = { "status": "finished", @@ -40165,11 +40506,11 @@ def _get_active_discovery_source(): Determine which music source is active for discovery. Returns the user's configured primary metadata source. 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() - if source == 'spotify' and not (spotify_client and spotify_client.is_spotify_authenticated()): - return 'deezer' - return source + from core.metadata_service import get_primary_source + return get_primary_source() @app.route('/api/discover/hero', methods=['GET']) @@ -43921,6 +44262,10 @@ def convert_listenbrainz_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], '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) elif result.get('spotify_track') and result.get('status_class') == 'found': # Build from individual fields (automatic discovery format) @@ -46135,8 +46480,8 @@ def _run_beatport_discovery_worker(url_hash): tracks = chart['tracks'] # Determine which provider to use - use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() + discovery_source = _get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Initialize fallback client if needed itunes_client_instance = None @@ -47025,8 +47370,8 @@ def prepare_mirrored_discovery(playlist_id): }) # Determine current active metadata source for provider-mismatch detection - _use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - _current_provider = 'spotify' if _use_spotify else _get_metadata_fallback_source() + _current_provider = _get_active_discovery_source() + _use_spotify = (_current_provider == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() # Check for cached discovery results in extra_data pre_discovered_results = [] @@ -47298,11 +47643,10 @@ def playlist_explorer_build_tree(): if not tracks: return jsonify({"success": False, "error": "Playlist has no tracks"}), 400 - # Determine active metadata source - spotify_available = spotify_client and spotify_client.is_spotify_authenticated() - if spotify_available: + # Determine active metadata source — respect user's configured primary + source_name = _get_active_discovery_source() + if source_name == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated(): active_client = spotify_client - source_name = 'spotify' else: active_client = _get_metadata_fallback_client() 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'] artists = [artist['name'] for artist in artists] - spotify_tracks.append({ + track = { 'id': spotify_data['id'], 'name': spotify_data['name'], 'artists': artists, 'album': spotify_data['album'], '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': # Build from individual fields (automatic discovery format) album_val = result.get('spotify_album', '') diff --git a/webui/index.html b/webui/index.html index 47ec78aa..d2783f65 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4595,6 +4595,14 @@ +
+ +
+ + +
+
+
+ Reduce Visual Effects + + Disables backdrop blur, animations, transitions, and shadows. Significantly reduces GPU/CPU usage on low-end devices. +
diff --git a/webui/static/script.js b/webui/static/script.js index cf3a08b9..aa83744b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -892,10 +892,30 @@ function initAccentColorListeners() { 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() { + if (localStorage.getItem('soulsync-reduce-effects') === '1') { + document.body.classList.add('reduce-effects'); + } const saved = localStorage.getItem('soulsync-accent'); if (saved) applyAccentColor(saved); // Bootstrap particles setting from localStorage @@ -5879,6 +5899,7 @@ async function loadSettingsData() { document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; 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 document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; @@ -6024,6 +6045,12 @@ async function loadSettingsData() { if (workerOrbsCheckbox) workerOrbsCheckbox.checked = 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) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; 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 }, library: { - music_paths: collectMusicPaths() + music_paths: collectMusicPaths(), + music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos' }, import: { 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', sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', 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: { cookies_browser: document.getElementById('youtube-cookies-browser').value, @@ -8177,7 +8206,8 @@ async function logoutQobuz() { const PATH_INPUT_IDS = { download: 'download-path', transfer: 'transfer-path', - staging: 'staging-path' + staging: 'staging-path', + 'music-videos': 'music-videos-path' }; function togglePathLock(pathType, btn) { @@ -8300,6 +8330,7 @@ function initializeSearchModeToggle() { deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, 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 @@ -8424,7 +8455,7 @@ function initializeSearchModeToggle() { // Fire ALL source fetches immediately in parallel with the primary endpoint. // 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. - for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) { + for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) { _fetchAlternateSource(srcName, query); } @@ -8482,6 +8513,9 @@ function initializeSearchModeToggle() { } 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) const displaySource = _activeSearchSource || data.metadata_source || '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 if (!_enhancedSearchData) return; 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]; @@ -8720,6 +8755,7 @@ function initializeSearchModeToggle() { 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 === '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; } // Re-render tabs + content if this is the active source @@ -8767,7 +8803,9 @@ function initializeSearchModeToggle() { tabBar.innerHTML = ordered.map(name => { const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; 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; return ``; }).join(''); } diff --git a/webui/static/style.css b/webui/static/style.css index 4e17c6eb..d5b371d9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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-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-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 { 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; } .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; +}