Merge branch 'Nezreka:main' into main

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

View file

@ -35,7 +35,7 @@ COPY . .
# Create necessary directories with proper permissions
# 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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
"""
Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider
Metadata Service - Centralized metadata source selection
Automatically uses Spotify when authenticated, falls back to the configured
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()

View file

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

View file

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

View file

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

View file

@ -61,8 +61,9 @@ class OrphanFileDetectorJob(RepairJob):
cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''")
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)

View file

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

View file

@ -161,8 +161,8 @@ class RepairWorker:
def itunes_client(self):
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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<video_id>', methods=['GET'])
def get_music_video_status(video_id):
"""Get download status for a music video."""
status = _music_video_downloads.get(video_id)
if not status:
return jsonify({"status": "unknown"})
return jsonify(status)
@app.route('/api/download', methods=['POST'])
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', '')

View file

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

View file

@ -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 `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')"
@ -8792,6 +8830,27 @@ function initializeSearchModeToggle() {
tab.classList.toggle('active', tab.dataset.source === sourceName);
});
// Music Videos tab — render video cards instead of regular sections
if (sourceName === 'youtube_videos') {
// Hide ALL regular sections including wrappers
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
});
// Hide the artists wrapper div too
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = 'none';
_renderVideoResults(src.videos || []);
resultsContainer.classList.remove('hidden');
return;
}
// Hide videos section and restore regular layout when switching to a metadata tab
const videosSec = document.getElementById('enh-videos-section');
if (videosSec) videosSec.classList.add('hidden');
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = '';
// Build data in the shape renderDropdownResults expects
const viewData = {
db_artists: _enhancedSearchData.db_artists,
@ -8824,6 +8883,70 @@ function initializeSearchModeToggle() {
}
};
function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section');
if (!section) {
// Create the section dynamically if it doesn't exist
const container = document.getElementById('enhanced-results-container');
if (!container) return;
section = document.createElement('div');
section.id = 'enh-videos-section';
section.className = 'enh-dropdown-section';
section.innerHTML = `
<div class="enh-section-header">
<span class="enh-section-icon">🎬</span>
<h4 class="enh-section-title">Music Videos</h4>
<span class="enh-section-count" id="enh-videos-count">0</span>
</div>
<div class="enh-video-grid" id="enh-videos-list"></div>
`;
container.appendChild(section);
}
section.classList.remove('hidden');
const countEl = document.getElementById('enh-videos-count');
const listEl = document.getElementById('enh-videos-list');
if (countEl) countEl.textContent = videos.length;
if (!videos.length) {
listEl.innerHTML = '<div class="enh-empty-state">No music videos found</div>';
return;
}
listEl.innerHTML = videos.map(v => {
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count ? _formatViewCount(v.view_count) : '';
return `
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${JSON.stringify(v).replace(/"/g, '&quot;')})">
<div class="enh-video-thumb">
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden">
<svg viewBox="0 0 36 36">
<circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/>
<circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/>
</svg>
</div>
<div class="enh-video-done hidden"></div>
<div class="enh-video-error hidden"></div>
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
</div>
<div class="enh-video-info">
<div class="enh-video-title" title="${v.title.replace(/"/g, '&quot;')}">${v.title}</div>
<div class="enh-video-channel">${v.channel}${views ? ` · ${views} views` : ''}</div>
</div>
</div>
`;
}).join('');
}
function _formatViewCount(count) {
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return String(count);
}
// Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [
@ -16269,9 +16392,13 @@ function updateCardToDefault(playlistId, finalState = null) {
// Check if any tracks were added to wishlist
const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0;
const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || [];
const playlistName = card.querySelector('.playlist-card-name').textContent;
if (wishlistCount > 0) {
if (wishlistCount > 0 && unmatchedTracks.length > 0) {
const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', ');
showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning');
} else if (wishlistCount > 0) {
showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success');
} else {
showToast(`Sync complete for "${playlistName}"`, 'success');
@ -17321,6 +17448,71 @@ function _notifTimeAgo(ts) {
}
// ==================================================================================
// Music video download handler — defined at top level so both enhanced and global search can use it
function _downloadMusicVideo(cardEl, video) {
if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return;
cardEl.classList.add('downloading');
cardEl.onclick = null;
const playBtn = cardEl.querySelector('.enh-video-play');
const progressRing = cardEl.querySelector('.enh-video-progress-ring');
const progressBar = cardEl.querySelector('.enh-video-progress-bar');
const doneIcon = cardEl.querySelector('.enh-video-done');
const errorIcon = cardEl.querySelector('.enh-video-error');
if (playBtn) playBtn.classList.add('hidden');
if (progressRing) progressRing.classList.remove('hidden');
fetch('/api/music-video/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }),
}).then(res => {
if (!res.ok) throw new Error('Download request failed');
const circumference = 97.4;
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/music-video/status/${video.video_id}`);
const status = await statusRes.json();
if (progressBar && status.progress > 0) {
progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference;
}
if (status.status === 'completed') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('completed');
if (progressRing) progressRing.classList.add('hidden');
if (doneIcon) doneIcon.classList.remove('hidden');
} else if (status.status === 'error') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('errored');
if (progressRing) progressRing.classList.add('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
}
} catch (e) {}
}, 500);
}).catch(e => {
cardEl.classList.remove('downloading');
if (progressRing) progressRing.classList.add('hidden');
if (playBtn) playBtn.classList.remove('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
});
}
// Global search video click — decodes base64 video data and delegates to _downloadMusicVideo
function _gsClickVideo(cardEl) {
try {
const encoded = cardEl.dataset.video;
const video = JSON.parse(decodeURIComponent(escape(atob(encoded))));
_downloadMusicVideo(cardEl, video);
} catch (e) {
console.error('Failed to parse video data:', e);
}
}
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere
// ==================================================================================
@ -17509,7 +17701,8 @@ async function _gsFetchSourceStream(src, query) {
if (!res.ok) return;
if (!_gsState.sources[src]) {
_gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) };
const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
}
const sourceData = _gsState.sources[src];
@ -17532,6 +17725,7 @@ async function _gsFetchSourceStream(src, query) {
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'); }
if (chunk.type === 'done') delete sourceData._loading;
_gsRenderTabs();
// Re-render content if this is the active source tab
@ -17551,6 +17745,43 @@ function _gsRender(data) {
const results = document.getElementById('gsearch-results');
if (!results) return;
// Music Videos tab — render video grid instead of regular results
if (_gsState.activeSource === 'youtube_videos') {
const src = _gsState.sources['youtube_videos'] || {};
const videos = src.videos || [];
const isLoading = src._loading && src._loading.size > 0;
let h = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
h += '<div class="gsearch-results-body">';
if (isLoading) {
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
} else if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
} else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">';
h += videos.map(v => {
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || '');
const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v))));
return `<div class="enh-video-card" data-video-id="${v.video_id}" data-video="${vJson}" onclick="_gsClickVideo(this)">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden"><svg viewBox="0 0 36 36"><circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/></svg></div>
<div class="enh-video-done hidden"></div><div class="enh-video-error hidden"></div>
${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
</div>`;
}).join('');
h += '</div>';
}
h += '</div>';
results.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
return;
}
const src = _gsState.sources[_gsState.activeSource] || {};
const loading = src._loading || new Set();
const dbArtists = data?.db_artists || [];
@ -17568,7 +17799,7 @@ function _gsRender(data) {
return;
}
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' };
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
let h = '';
@ -17665,11 +17896,13 @@ function _gsRenderTabs() {
if (!el) return;
const sources = Object.keys(_gsState.sources);
if (sources.length < 2) { el.style.display = 'none'; return; }
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' };
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
el.style.display = 'flex';
el.innerHTML = sources.map(s => {
const d = _gsState.sources[s];
const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
const c = s === 'youtube_videos'
? (d.videos?.length || 0)
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
}).join('');
}

View file

@ -32887,6 +32887,188 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
.enh-source-tab.enh-tab-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;
}