Merge branch 'Nezreka:main' into main
This commit is contained in:
commit
54df01f137
22 changed files with 1878 additions and 191 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
|
|
|
|||
|
|
@ -461,7 +461,8 @@ class ConfigManager:
|
|||
"poll_interval": 30
|
||||
},
|
||||
"library": {
|
||||
"music_paths": []
|
||||
"music_paths": [],
|
||||
"music_videos_path": "./MusicVideos"
|
||||
},
|
||||
"scripts": {
|
||||
"path": "./scripts",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
496
core/repair_jobs/unknown_artist_fixer.py
Normal file
496
core/repair_jobs/unknown_artist_fixer.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
465
web_server.py
465
web_server.py
|
|
@ -855,13 +855,18 @@ def _register_automation_handlers():
|
|||
md = extra['matched_data']
|
||||
album_raw = md.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
tracks_json.append({
|
||||
_track_entry = {
|
||||
'name': md.get('name', ''),
|
||||
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
|
||||
'album': album_obj,
|
||||
'duration_ms': md.get('duration_ms', 0),
|
||||
'id': md.get('id', ''),
|
||||
})
|
||||
}
|
||||
if md.get('track_number'):
|
||||
_track_entry['track_number'] = md['track_number']
|
||||
if md.get('disc_number'):
|
||||
_track_entry['disc_number'] = md['disc_number']
|
||||
tracks_json.append(_track_entry)
|
||||
else:
|
||||
# NOT discovered — try to include using available metadata so the
|
||||
# track can still be searched on Soulseek and added to wishlist.
|
||||
|
|
@ -5179,7 +5184,7 @@ def handle_settings():
|
|||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -7868,6 +7873,8 @@ def enhanced_search():
|
|||
alternate_sources.append('discogs')
|
||||
if primary_source != 'hydrabase' and hydrabase_available:
|
||||
alternate_sources.append('hydrabase')
|
||||
# YouTube music videos always available (uses yt-dlp, no auth needed)
|
||||
alternate_sources.append('youtube_videos')
|
||||
|
||||
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
|
||||
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
|
||||
|
|
@ -7954,7 +7961,7 @@ def enhanced_search_source(source_name):
|
|||
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
|
||||
Falls back to single JSON response if streaming not supported.
|
||||
"""
|
||||
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'):
|
||||
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'):
|
||||
return jsonify({"error": f"Unknown source: {source_name}"}), 400
|
||||
|
||||
data = request.get_json()
|
||||
|
|
@ -7962,6 +7969,37 @@ def enhanced_search_source(source_name):
|
|||
if not query:
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
|
||||
# YouTube music videos — separate flow from metadata sources
|
||||
if source_name == 'youtube_videos':
|
||||
if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube:
|
||||
return jsonify({"videos": [], "available": False})
|
||||
try:
|
||||
def generate_videos():
|
||||
try:
|
||||
# Search YouTube via yt-dlp
|
||||
video_query = f"{query} official music video"
|
||||
results = run_async(soulseek_client.youtube.search_videos(video_query, max_results=20))
|
||||
videos = []
|
||||
for v in (results or []):
|
||||
videos.append({
|
||||
'video_id': v.video_id,
|
||||
'title': v.title,
|
||||
'channel': v.channel,
|
||||
'duration': v.duration,
|
||||
'thumbnail': v.thumbnail,
|
||||
'url': v.url,
|
||||
'view_count': v.view_count,
|
||||
'upload_date': v.upload_date,
|
||||
})
|
||||
yield json.dumps({"type": "videos", "data": videos}) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube music video search failed: {e}")
|
||||
yield json.dumps({"type": "videos", "data": []}) + "\n"
|
||||
yield json.dumps({"type": "done"}) + "\n"
|
||||
return app.response_class(generate_videos(), mimetype='application/x-ndjson')
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
try:
|
||||
client = None
|
||||
if source_name == 'spotify':
|
||||
|
|
@ -8308,6 +8346,138 @@ def stream_enhanced_search_track():
|
|||
logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# =============================================================================
|
||||
# MUSIC VIDEO DOWNLOADS
|
||||
# =============================================================================
|
||||
|
||||
_music_video_downloads = {} # {video_id: {status, progress, path, error}}
|
||||
|
||||
@app.route('/api/music-video/download', methods=['POST'])
|
||||
def download_music_video():
|
||||
"""Download a YouTube video as a music video file to the configured music videos folder."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data"}), 400
|
||||
|
||||
video_id = data.get('video_id', '')
|
||||
video_url = data.get('url', '')
|
||||
raw_title = data.get('title', '')
|
||||
raw_channel = data.get('channel', '')
|
||||
|
||||
if not video_id or not video_url:
|
||||
return jsonify({"error": "Missing video_id or url"}), 400
|
||||
|
||||
# Check if already downloading
|
||||
if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading':
|
||||
return jsonify({"error": "Already downloading"}), 409
|
||||
|
||||
# Get music videos path
|
||||
music_videos_path = config_manager.get('library.music_videos_path', './MusicVideos')
|
||||
music_videos_path = docker_resolve_path(music_videos_path)
|
||||
os.makedirs(music_videos_path, exist_ok=True)
|
||||
|
||||
# Initialize download state
|
||||
_music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None}
|
||||
|
||||
def _do_download():
|
||||
try:
|
||||
# Step 1: Try to match against primary metadata source for clean artist/title
|
||||
_music_video_downloads[video_id]['status'] = 'matching'
|
||||
artist_name = raw_channel
|
||||
track_title = raw_title
|
||||
|
||||
# Strip common YouTube suffixes for cleaner search
|
||||
import re as _re
|
||||
clean_search = _re.sub(r'\s*[\(\[](official\s*(music\s*)?video|official\s*lyric\s*video|official\s*audio|official\s*hd|hd|4k|remastered|lyric\s*video|visualizer|audio)[\)\]]', '', raw_title, flags=_re.IGNORECASE).strip()
|
||||
clean_search = _re.sub(r'\s*-\s*$', '', clean_search).strip()
|
||||
|
||||
try:
|
||||
fallback_client = _get_metadata_fallback_client()
|
||||
results = fallback_client.search_tracks(clean_search, limit=5)
|
||||
if results:
|
||||
from difflib import SequenceMatcher
|
||||
best = None
|
||||
best_score = 0
|
||||
for r in results:
|
||||
name_sim = SequenceMatcher(None, clean_search.lower(), r.name.lower()).ratio()
|
||||
if r.artists:
|
||||
artist_sim = SequenceMatcher(None, raw_channel.lower(), r.artists[0].lower()).ratio()
|
||||
name_sim = (name_sim * 0.6) + (artist_sim * 0.4)
|
||||
if name_sim > best_score:
|
||||
best_score = name_sim
|
||||
best = r
|
||||
if best and best_score >= 0.5:
|
||||
artist_name = best.artists[0] if best.artists else raw_channel
|
||||
track_title = best.name
|
||||
print(f"🎬 [Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})")
|
||||
else:
|
||||
# Parse artist from video title: "Artist - Title" pattern
|
||||
if ' - ' in raw_title:
|
||||
parts = raw_title.split(' - ', 1)
|
||||
artist_name = parts[0].strip()
|
||||
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
|
||||
print(f"🎬 [Music Video] No metadata match, using parsed: {artist_name} - {track_title}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Music Video] Metadata lookup failed: {e}")
|
||||
if ' - ' in raw_title:
|
||||
parts = raw_title.split(' - ', 1)
|
||||
artist_name = parts[0].strip()
|
||||
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
|
||||
|
||||
# Sanitize for filesystem
|
||||
def _sanitize(s):
|
||||
return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.')
|
||||
|
||||
artist_folder = _sanitize(artist_name)
|
||||
video_filename = f"{_sanitize(track_title)}-video"
|
||||
|
||||
# Build output path: MusicVideos/Artist/Title-video
|
||||
artist_dir = os.path.join(music_videos_path, artist_folder)
|
||||
os.makedirs(artist_dir, exist_ok=True)
|
||||
output_path = os.path.join(artist_dir, video_filename)
|
||||
|
||||
# Step 2: Download
|
||||
_music_video_downloads[video_id]['status'] = 'downloading'
|
||||
_music_video_downloads[video_id]['artist'] = artist_name
|
||||
_music_video_downloads[video_id]['title'] = track_title
|
||||
|
||||
def _progress(pct):
|
||||
_music_video_downloads[video_id]['progress'] = round(pct, 1)
|
||||
|
||||
final_path = soulseek_client.youtube.download_music_video(video_url, output_path, progress_callback=_progress)
|
||||
|
||||
if final_path and os.path.exists(final_path):
|
||||
_music_video_downloads[video_id]['status'] = 'completed'
|
||||
_music_video_downloads[video_id]['progress'] = 100
|
||||
_music_video_downloads[video_id]['path'] = final_path
|
||||
print(f"✅ [Music Video] Downloaded: {artist_name} - {track_title} → {final_path}")
|
||||
add_activity_item("🎬", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now")
|
||||
else:
|
||||
_music_video_downloads[video_id]['status'] = 'error'
|
||||
_music_video_downloads[video_id]['error'] = 'Download failed — file not found'
|
||||
print(f"❌ [Music Video] Download failed for: {artist_name} - {track_title}")
|
||||
|
||||
except Exception as e:
|
||||
_music_video_downloads[video_id]['status'] = 'error'
|
||||
_music_video_downloads[video_id]['error'] = str(e)
|
||||
print(f"❌ [Music Video] Error: {e}")
|
||||
|
||||
# Run in background thread
|
||||
import threading
|
||||
threading.Thread(target=_do_download, daemon=True, name=f'music-video-{video_id}').start()
|
||||
|
||||
return jsonify({"success": True, "video_id": video_id})
|
||||
|
||||
|
||||
@app.route('/api/music-video/status/<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', '')
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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, '"')})">
|
||||
<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, '"')}">${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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue