Merge branch 'Nezreka:main' into main
This commit is contained in:
commit
16fbba018f
52 changed files with 7346 additions and 4342 deletions
|
|
@ -61,6 +61,9 @@ main.py
|
||||||
ui/
|
ui/
|
||||||
requirements.txt
|
requirements.txt
|
||||||
|
|
||||||
|
# Dev-specific files
|
||||||
|
requirements-dev.txt
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
|
|
@ -68,4 +71,4 @@ requirements.txt
|
||||||
.Spotlight-V100
|
.Spotlight-V100
|
||||||
.Trashes
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
39
Dockerfile
39
Dockerfile
|
|
@ -1,21 +1,41 @@
|
||||||
# SoulSync WebUI Dockerfile
|
# SoulSync WebUI Dockerfile
|
||||||
# Multi-architecture support for AMD64 and ARM64
|
# Multi-architecture support for AMD64 and ARM64
|
||||||
|
|
||||||
|
# Stage 1: Builder — install Python dependencies with compilation tools
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create virtualenv and install dependencies
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY requirements-webui.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements-webui.txt
|
||||||
|
|
||||||
|
# Stage 2: Runtime — only runtime dependencies, no build tools
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Build-time commit SHA for update detection
|
# Build-time commit SHA for update detection
|
||||||
ARG COMMIT_SHA=""
|
ARG COMMIT_SHA=""
|
||||||
ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA}
|
ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA}
|
||||||
|
|
||||||
|
# Copy pre-built virtualenv from builder
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install runtime-only system dependencies (no gcc/build tools)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
|
||||||
libc6-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libssl-dev \
|
|
||||||
curl \
|
curl \
|
||||||
gosu \
|
gosu \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
|
@ -25,11 +45,6 @@ RUN apt-get update && apt-get install -y \
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
|
||||||
COPY requirements-webui.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir -r requirements-webui.txt
|
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
@ -75,4 +90,4 @@ ENV UMASK=022
|
||||||
|
|
||||||
# Set entrypoint and default command
|
# Set entrypoint and default command
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["python", "web_server.py"]
|
CMD ["python", "web_server.py"]
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,13 @@ python web_server.py
|
||||||
# Open http://localhost:8008
|
# Open http://localhost:8008
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For local development and tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Guide
|
## Setup Guide
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -33,7 +33,7 @@ class ConfigManager:
|
||||||
# Default to project path even if it doesn't exist yet (for creation/fallback)
|
# Default to project path even if it doesn't exist yet (for creation/fallback)
|
||||||
self.config_path = project_path
|
self.config_path = project_path
|
||||||
|
|
||||||
print(f"🔧 ConfigManager initialized with path: {self.config_path}")
|
print(f"ConfigManager initialized with path: {self.config_path}")
|
||||||
|
|
||||||
self.config_data: Dict[str, Any] = {}
|
self.config_data: Dict[str, Any] = {}
|
||||||
self._fernet: Optional[Fernet] = None
|
self._fernet: Optional[Fernet] = None
|
||||||
|
|
@ -45,7 +45,7 @@ class ConfigManager:
|
||||||
else:
|
else:
|
||||||
self.database_path = self.base_dir / "database" / "music_library.db"
|
self.database_path = self.base_dir / "database" / "music_library.db"
|
||||||
|
|
||||||
print(f"💾 Database path set to: {self.database_path}")
|
print(f"Database path set to: {self.database_path}")
|
||||||
|
|
||||||
self.load_config(str(self.config_path))
|
self.load_config(str(self.config_path))
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ class ConfigManager:
|
||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
shutil.move(str(old_key_file), str(key_file))
|
shutil.move(str(old_key_file), str(key_file))
|
||||||
print(f"[MIGRATE] 🔑 Moved encryption key to {key_file}")
|
print(f"[MIGRATE] Moved encryption key to {key_file}")
|
||||||
except Exception:
|
except Exception:
|
||||||
key_file = old_key_file # Fall back to old location
|
key_file = old_key_file # Fall back to old location
|
||||||
if key_file.exists():
|
if key_file.exists():
|
||||||
|
|
@ -155,7 +155,7 @@ class ConfigManager:
|
||||||
return decrypted
|
return decrypted
|
||||||
except InvalidToken:
|
except InvalidToken:
|
||||||
# Key mismatch — encrypted with a different key (key file deleted/replaced)
|
# Key mismatch — encrypted with a different key (key file deleted/replaced)
|
||||||
print(f"[ERROR] ⚠️ Failed to decrypt a config value — encryption key may have changed. "
|
print(f"[ERROR] Failed to decrypt a config value — encryption key may have changed. "
|
||||||
f"Re-enter credentials in Settings or restore the original .encryption_key file.")
|
f"Re-enter credentials in Settings or restore the original .encryption_key file.")
|
||||||
return value
|
return value
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -243,9 +243,9 @@ class ConfigManager:
|
||||||
needs_migration = True
|
needs_migration = True
|
||||||
break
|
break
|
||||||
if needs_migration:
|
if needs_migration:
|
||||||
print("[MIGRATE] 🔐 Encrypting sensitive config values at rest...")
|
print("[MIGRATE] Encrypting sensitive config values at rest...")
|
||||||
self._save_to_database(self.config_data)
|
self._save_to_database(self.config_data)
|
||||||
print("[OK] ✅ Sensitive config values encrypted successfully")
|
print("[OK] Sensitive config values encrypted successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] Could not migrate encryption: {e}")
|
print(f"[WARN] Could not migrate encryption: {e}")
|
||||||
|
|
||||||
|
|
@ -505,7 +505,7 @@ class ConfigManager:
|
||||||
2. config.json (migration from file-based config)
|
2. config.json (migration from file-based config)
|
||||||
3. Defaults (fresh install)
|
3. Defaults (fresh install)
|
||||||
"""
|
"""
|
||||||
print(f"📥 Loading configuration...")
|
print(f"Loading configuration...")
|
||||||
|
|
||||||
# Try loading from database first
|
# Try loading from database first
|
||||||
config_data = self._load_from_database()
|
config_data = self._load_from_database()
|
||||||
|
|
@ -518,18 +518,18 @@ class ConfigManager:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Database is empty - try migration from config.json
|
# Database is empty - try migration from config.json
|
||||||
print(f"⚠️ Configuration not found in database. Attempting migration from: {self.config_path}")
|
print(f"Configuration not found in database. Attempting migration from: {self.config_path}")
|
||||||
config_data = self._load_from_config_file()
|
config_data = self._load_from_config_file()
|
||||||
|
|
||||||
if config_data:
|
if config_data:
|
||||||
# Migrate from config.json to database
|
# Migrate from config.json to database
|
||||||
print("[MIGRATE] 🚀 Migrating configuration from config.json to database...")
|
print("[MIGRATE] Migrating configuration from config.json to database...")
|
||||||
if self._save_to_database(config_data):
|
if self._save_to_database(config_data):
|
||||||
print("[OK] ✅ Configuration migrated successfully to database.")
|
print("[OK] Configuration migrated successfully to database.")
|
||||||
self.config_data = config_data
|
self.config_data = config_data
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
print("[WARN] ⚠️ Migration failed - using file-based config temporarily.")
|
print("[WARN] Migration failed - using file-based config temporarily.")
|
||||||
self.config_data = config_data
|
self.config_data = config_data
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -539,9 +539,9 @@ class ConfigManager:
|
||||||
|
|
||||||
# Try to save defaults to database
|
# Try to save defaults to database
|
||||||
if self._save_to_database(config_data):
|
if self._save_to_database(config_data):
|
||||||
print("[OK] ✅ Default configuration saved to database")
|
print("[OK] Default configuration saved to database")
|
||||||
else:
|
else:
|
||||||
print("[WARN] ⚠️ Could not save defaults to database - using in-memory config")
|
print("[WARN] Could not save defaults to database - using in-memory config")
|
||||||
|
|
||||||
self.config_data = config_data
|
self.config_data = config_data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
else:
|
else:
|
||||||
freed_items = "unknown"
|
freed_items = "unknown"
|
||||||
self.media_client.clear_cache()
|
self.media_client.clear_cache()
|
||||||
logger.info(f"🧹 Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
|
logger.info(f"Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
# Connect Navidrome client progress to UI
|
# Connect Navidrome client progress to UI
|
||||||
if hasattr(self.media_client, 'set_progress_callback'):
|
if hasattr(self.media_client, 'set_progress_callback'):
|
||||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
||||||
logger.info("✅ Connected Navidrome progress callback")
|
logger.info("Connected Navidrome progress callback")
|
||||||
|
|
||||||
# For full refresh, get all artists
|
# For full refresh, get all artists
|
||||||
artists_to_process = self._get_all_artists()
|
artists_to_process = self._get_all_artists()
|
||||||
|
|
@ -189,7 +189,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
merge_results = self.database.merge_duplicate_artists()
|
merge_results = self.database.merge_duplicate_artists()
|
||||||
merged = merge_results.get('artists_merged', 0)
|
merged = merge_results.get('artists_merged', 0)
|
||||||
if merged > 0:
|
if merged > 0:
|
||||||
logger.info(f"🧹 Merged {merged} duplicate artists")
|
logger.info(f"Merged {merged} duplicate artists")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||||
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
||||||
|
|
@ -204,7 +204,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
||||||
else:
|
else:
|
||||||
# Standard artist processing for Plex or full refresh
|
# Standard artist processing for Plex or full refresh
|
||||||
logger.info(f"🎯 About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
|
logger.info(f"About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
|
||||||
self._process_all_artists(artists_to_process)
|
self._process_all_artists(artists_to_process)
|
||||||
|
|
||||||
# Record full refresh completion for tracking purposes
|
# Record full refresh completion for tracking purposes
|
||||||
|
|
@ -224,7 +224,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
else:
|
else:
|
||||||
freed_items = "cache data"
|
freed_items = "cache data"
|
||||||
self.media_client.clear_cache()
|
self.media_client.clear_cache()
|
||||||
logger.info(f"🧹 Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
|
logger.info(f"Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear {self.server_type} cache: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache: {e}")
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
r_albums = removal_results.get('albums_removed', 0)
|
r_albums = removal_results.get('albums_removed', 0)
|
||||||
r_tracks = removal_results.get('tracks_removed', 0)
|
r_tracks = removal_results.get('tracks_removed', 0)
|
||||||
if r_artists > 0 or r_albums > 0:
|
if r_artists > 0 or r_albums > 0:
|
||||||
logger.info(f"🗑️ Removal detection: {r_artists} artists, "
|
logger.info(f"Removal detection: {r_artists} artists, "
|
||||||
f"{r_albums} albums, {r_tracks} tracks removed")
|
f"{r_albums} albums, {r_tracks} tracks removed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Removal detection failed (non-fatal): {e}")
|
logger.warning(f"Removal detection failed (non-fatal): {e}")
|
||||||
|
|
@ -253,9 +253,9 @@ class DatabaseUpdateWorker(QThread):
|
||||||
orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0)
|
orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0)
|
||||||
|
|
||||||
if orphaned_artists > 0 or orphaned_albums > 0:
|
if orphaned_artists > 0 or orphaned_albums > 0:
|
||||||
logger.info(f"🧹 Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed")
|
logger.info(f"Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed")
|
||||||
else:
|
else:
|
||||||
logger.debug("🧹 Cleanup complete: No orphaned records found")
|
logger.debug("Cleanup complete: No orphaned records found")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not cleanup orphaned records: {e}")
|
logger.warning(f"Could not cleanup orphaned records: {e}")
|
||||||
|
|
@ -265,7 +265,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
merge_results = self.database.merge_duplicate_artists()
|
merge_results = self.database.merge_duplicate_artists()
|
||||||
merged = merge_results.get('artists_merged', 0)
|
merged = merge_results.get('artists_merged', 0)
|
||||||
if merged > 0:
|
if merged > 0:
|
||||||
logger.info(f"🧹 Merged {merged} duplicate artists")
|
logger.info(f"Merged {merged} duplicate artists")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||||
|
|
||||||
|
|
@ -437,9 +437,9 @@ class DatabaseUpdateWorker(QThread):
|
||||||
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info(f"🎯 _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
|
logger.info(f"_get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
|
||||||
artists = self.media_client.get_all_artists()
|
artists = self.media_client.get_all_artists()
|
||||||
logger.info(f"🎯 _get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
|
logger.info(f"_get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -581,9 +581,9 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if not track_exists:
|
if not track_exists:
|
||||||
missing_tracks_count += 1
|
missing_tracks_count += 1
|
||||||
album_has_new_tracks = True
|
album_has_new_tracks = True
|
||||||
logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
|
logger.debug(f"Track '{track_title}' is new - album needs processing")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"✅ Track '{track_title}' already exists")
|
logger.debug(f"Track '{track_title}' already exists")
|
||||||
|
|
||||||
except Exception as track_error:
|
except Exception as track_error:
|
||||||
logger.debug(f"Error checking individual track: {track_error}")
|
logger.debug(f"Error checking individual track: {track_error}")
|
||||||
|
|
@ -595,23 +595,23 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if album_has_new_tracks:
|
if album_has_new_tracks:
|
||||||
albums_with_new_content += 1
|
albums_with_new_content += 1
|
||||||
consecutive_complete_albums = 0 # Reset counter
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
logger.info(f"📀 Album '{album_title}' has {missing_tracks_count} new tracks - needs processing")
|
logger.info(f"Album '{album_title}' has {missing_tracks_count} new tracks - needs processing")
|
||||||
else:
|
else:
|
||||||
# Check if existing tracks have metadata changes (catches Plex corrections)
|
# Check if existing tracks have metadata changes (catches Plex corrections)
|
||||||
metadata_changed = self._check_for_metadata_changes(tracks)
|
metadata_changed = self._check_for_metadata_changes(tracks)
|
||||||
if metadata_changed:
|
if metadata_changed:
|
||||||
albums_with_new_content += 1
|
albums_with_new_content += 1
|
||||||
consecutive_complete_albums = 0 # Reset counter
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
logger.info(f"🔄 Album '{album_title}' has metadata changes - needs processing")
|
logger.info(f"Album '{album_title}' has metadata changes - needs processing")
|
||||||
album_has_new_tracks = True # Mark for artist processing
|
album_has_new_tracks = True # Mark for artist processing
|
||||||
else:
|
else:
|
||||||
consecutive_complete_albums += 1
|
consecutive_complete_albums += 1
|
||||||
logger.debug(f"✅ Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})")
|
logger.debug(f"Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})")
|
||||||
|
|
||||||
# Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes
|
# Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes
|
||||||
# This ensures we don't miss scattered updated content from manual corrections
|
# This ensures we don't miss scattered updated content from manual corrections
|
||||||
if consecutive_complete_albums >= 25:
|
if consecutive_complete_albums >= 25:
|
||||||
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
||||||
stopped_early = True
|
stopped_early = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -633,7 +633,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if artist_id not in processed_artist_ids:
|
if artist_id not in processed_artist_ids:
|
||||||
processed_artist_ids.add(artist_id)
|
processed_artist_ids.add(artist_id)
|
||||||
artists_to_process.append(album_artist)
|
artists_to_process.append(album_artist)
|
||||||
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
||||||
except Exception as artist_error:
|
except Exception as artist_error:
|
||||||
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
||||||
|
|
||||||
|
|
@ -649,7 +649,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
else:
|
else:
|
||||||
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
|
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
|
||||||
|
|
||||||
logger.info(f"📊 Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing")
|
logger.info(f"Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing")
|
||||||
|
|
||||||
logger.info(result_msg)
|
logger.info(result_msg)
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
@ -662,7 +662,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
def _get_artists_for_navidrome_incremental_update(self) -> List:
|
def _get_artists_for_navidrome_incremental_update(self) -> List:
|
||||||
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
|
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
|
||||||
try:
|
try:
|
||||||
logger.info("🎵 Navidrome incremental: Getting recent albums and checking for new content...")
|
logger.info("Navidrome incremental: Getting recent albums and checking for new content...")
|
||||||
|
|
||||||
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
|
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
|
||||||
recent_albums = self._get_recent_albums_for_server()
|
recent_albums = self._get_recent_albums_for_server()
|
||||||
|
|
@ -720,11 +720,11 @@ class DatabaseUpdateWorker(QThread):
|
||||||
# If no new tracks found, increment consecutive complete counter
|
# If no new tracks found, increment consecutive complete counter
|
||||||
if not album_has_new_tracks:
|
if not album_has_new_tracks:
|
||||||
consecutive_complete_albums += 1
|
consecutive_complete_albums += 1
|
||||||
logger.debug(f"✅ Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
|
logger.debug(f"Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
|
||||||
|
|
||||||
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
|
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
|
||||||
if consecutive_complete_albums >= 25:
|
if consecutive_complete_albums >= 25:
|
||||||
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as tracks_error:
|
except Exception as tracks_error:
|
||||||
|
|
@ -744,7 +744,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if artist_id not in processed_artist_ids:
|
if artist_id not in processed_artist_ids:
|
||||||
processed_artist_ids.add(artist_id)
|
processed_artist_ids.add(artist_id)
|
||||||
artists_to_process.append(album_artist)
|
artists_to_process.append(album_artist)
|
||||||
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
||||||
except Exception as artist_error:
|
except Exception as artist_error:
|
||||||
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
||||||
|
|
||||||
|
|
@ -753,7 +753,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
consecutive_complete_albums = 0 # Reset on error
|
consecutive_complete_albums = 0 # Reset on error
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"🎵 Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
|
logger.info(f"Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -763,7 +763,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
||||||
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
||||||
try:
|
try:
|
||||||
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...")
|
logger.info("FAST Jellyfin incremental: getting recent tracks directly...")
|
||||||
|
|
||||||
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
|
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
|
||||||
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
|
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
|
||||||
|
|
@ -799,14 +799,14 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if not track_exists:
|
if not track_exists:
|
||||||
new_tracks.append(track)
|
new_tracks.append(track)
|
||||||
consecutive_existing_tracks = 0 # Reset counter
|
consecutive_existing_tracks = 0 # Reset counter
|
||||||
logger.debug(f"🎵 New track: {track.title}")
|
logger.debug(f"New track: {track.title}")
|
||||||
else:
|
else:
|
||||||
consecutive_existing_tracks += 1
|
consecutive_existing_tracks += 1
|
||||||
logger.debug(f"✅ Track exists: {track.title}")
|
logger.debug(f"Track exists: {track.title}")
|
||||||
|
|
||||||
# Early stopping: if we find 100 consecutive existing tracks, we're done
|
# Early stopping: if we find 100 consecutive existing tracks, we're done
|
||||||
if consecutive_existing_tracks >= 100:
|
if consecutive_existing_tracks >= 100:
|
||||||
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
|
logger.info(f"Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -833,12 +833,12 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if artist_id not in processed_artists:
|
if artist_id not in processed_artists:
|
||||||
processed_artists.add(artist_id)
|
processed_artists.add(artist_id)
|
||||||
artists_to_process.append(track_artist)
|
artists_to_process.append(track_artist)
|
||||||
logger.info(f"✅ Added artist '{track_artist.title}' (from new track '{track.title}')")
|
logger.info(f"Added artist '{track_artist.title}' (from new track '{track.title}')")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
|
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
|
logger.info(f"FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -853,7 +853,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
logger.warning("No new tracks to process directly")
|
logger.warning("No new tracks to process directly")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
|
logger.info(f"FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
|
||||||
|
|
||||||
# Group tracks by album and artist for efficient processing
|
# Group tracks by album and artist for efficient processing
|
||||||
tracks_by_album = {}
|
tracks_by_album = {}
|
||||||
|
|
@ -921,7 +921,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||||
if track_success:
|
if track_success:
|
||||||
total_processed_tracks += 1
|
total_processed_tracks += 1
|
||||||
logger.debug(f"✅ Processed new track: {track.title}")
|
logger.debug(f"Processed new track: {track.title}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -943,7 +943,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
self.processed_tracks += total_processed_tracks
|
self.processed_tracks += total_processed_tracks
|
||||||
self.successful_operations += total_processed_artists # Count successful artists
|
self.successful_operations += total_processed_artists # Count successful artists
|
||||||
|
|
||||||
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
|
logger.info(f"FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
delattr(self, '_jellyfin_new_tracks')
|
delattr(self, '_jellyfin_new_tracks')
|
||||||
|
|
@ -976,7 +976,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if (db_track.title != current_title or
|
if (db_track.title != current_title or
|
||||||
db_track.artist_name != current_artist or
|
db_track.artist_name != current_artist or
|
||||||
db_track.album_title != current_album):
|
db_track.album_title != current_album):
|
||||||
logger.debug(f"🔄 Metadata change detected for track ID {track_id}:")
|
logger.debug(f"Metadata change detected for track ID {track_id}:")
|
||||||
logger.debug(f" Title: '{db_track.title}' → '{current_title}'")
|
logger.debug(f" Title: '{db_track.title}' → '{current_title}'")
|
||||||
logger.debug(f" Artist: '{db_track.artist_name}' → '{current_artist}'")
|
logger.debug(f" Artist: '{db_track.artist_name}' → '{current_artist}'")
|
||||||
logger.debug(f" Album: '{db_track.album_title}' → '{current_album}'")
|
logger.debug(f" Album: '{db_track.album_title}' → '{current_album}'")
|
||||||
|
|
@ -987,7 +987,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if changes_detected > 0:
|
if changes_detected > 0:
|
||||||
logger.info(f"🔄 Found {changes_detected} tracks with metadata changes")
|
logger.info(f"Found {changes_detected} tracks with metadata changes")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
@ -1014,7 +1014,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Fetch current IDs from media server (lightweight calls)
|
# Fetch current IDs from media server (lightweight calls)
|
||||||
logger.info(f"🔍 Removal detection: fetching current IDs from {self.server_type}...")
|
logger.info(f"Removal detection: fetching current IDs from {self.server_type}...")
|
||||||
self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...")
|
self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...")
|
||||||
server_artist_ids = self.media_client.get_all_artist_ids()
|
server_artist_ids = self.media_client.get_all_artist_ids()
|
||||||
self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...")
|
self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...")
|
||||||
|
|
@ -1022,7 +1022,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
|
|
||||||
# Safety: if both come back empty, the server is unreachable
|
# Safety: if both come back empty, the server is unreachable
|
||||||
if not server_artist_ids and not server_album_ids:
|
if not server_artist_ids and not server_album_ids:
|
||||||
logger.warning("🛡️ SAFETY: Server returned zero artists AND zero albums — "
|
logger.warning("SAFETY: Server returned zero artists AND zero albums — "
|
||||||
"skipping removal detection")
|
"skipping removal detection")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1044,19 +1044,19 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if check_artists and db_artist_count > 100:
|
if check_artists and db_artist_count > 100:
|
||||||
if len(server_artist_ids) < db_artist_count * 0.5:
|
if len(server_artist_ids) < db_artist_count * 0.5:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"🛡️ SAFETY: Server reported {len(server_artist_ids)} artists but "
|
f"SAFETY: Server reported {len(server_artist_ids)} artists but "
|
||||||
f"database has {db_artist_count} — skipping artist removal check")
|
f"database has {db_artist_count} — skipping artist removal check")
|
||||||
check_artists = False
|
check_artists = False
|
||||||
|
|
||||||
if check_albums and db_album_count > 100:
|
if check_albums and db_album_count > 100:
|
||||||
if len(server_album_ids) < db_album_count * 0.5:
|
if len(server_album_ids) < db_album_count * 0.5:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"🛡️ SAFETY: Server reported {len(server_album_ids)} albums but "
|
f"SAFETY: Server reported {len(server_album_ids)} albums but "
|
||||||
f"database has {db_album_count} — skipping album removal check")
|
f"database has {db_album_count} — skipping album removal check")
|
||||||
check_albums = False
|
check_albums = False
|
||||||
|
|
||||||
if not check_artists and not check_albums:
|
if not check_artists and not check_albums:
|
||||||
logger.warning("🛡️ SAFETY: Both artist and album checks disabled — "
|
logger.warning("SAFETY: Both artist and album checks disabled — "
|
||||||
"skipping removal detection")
|
"skipping removal detection")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1090,11 +1090,11 @@ class DatabaseUpdateWorker(QThread):
|
||||||
pass # If this optimization fails, double-delete is harmless
|
pass # If this optimization fails, double-delete is harmless
|
||||||
|
|
||||||
if not removed_artist_ids and not removed_album_ids:
|
if not removed_artist_ids and not removed_album_ids:
|
||||||
logger.info("🔍 Removal detection: no stale content found")
|
logger.info("Removal detection: no stale content found")
|
||||||
self._emit_signal('phase_changed', "No removed content detected")
|
self._emit_signal('phase_changed', "No removed content detected")
|
||||||
return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}
|
return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}
|
||||||
|
|
||||||
logger.info(f"🗑️ Removal detection: found {len(removed_artist_ids)} removed artists, "
|
logger.info(f"Removal detection: found {len(removed_artist_ids)} removed artists, "
|
||||||
f"{len(removed_album_ids)} removed albums")
|
f"{len(removed_album_ids)} removed albums")
|
||||||
|
|
||||||
self._emit_signal('phase_changed',
|
self._emit_signal('phase_changed',
|
||||||
|
|
@ -1219,7 +1219,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
def _process_all_artists(self, artists: List):
|
def _process_all_artists(self, artists: List):
|
||||||
"""Process all artists and their albums/tracks using thread pool"""
|
"""Process all artists and their albums/tracks using thread pool"""
|
||||||
total_artists = len(artists)
|
total_artists = len(artists)
|
||||||
logger.info(f"🎯 Processing {total_artists} artists with progress tracking")
|
logger.info(f"Processing {total_artists} artists with progress tracking")
|
||||||
|
|
||||||
def process_single_artist(artist):
|
def process_single_artist(artist):
|
||||||
"""Process a single artist and return results"""
|
"""Process a single artist and return results"""
|
||||||
|
|
@ -1240,7 +1240,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
total_artists,
|
total_artists,
|
||||||
progress_percent
|
progress_percent
|
||||||
)
|
)
|
||||||
logger.debug(f"🔄 Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
|
logger.debug(f"Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
|
||||||
|
|
||||||
# Process the artist
|
# Process the artist
|
||||||
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class DownloadOrchestrator:
|
||||||
self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient)
|
self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient)
|
||||||
|
|
||||||
if self._init_failures:
|
if self._init_failures:
|
||||||
logger.warning(f"⚠️ Download clients failed to initialize: {', '.join(self._init_failures)}")
|
logger.warning(f"Download clients failed to initialize: {', '.join(self._init_failures)}")
|
||||||
|
|
||||||
# Load mode from config
|
# Load mode from config
|
||||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||||
|
|
@ -59,7 +59,7 @@ class DownloadOrchestrator:
|
||||||
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
|
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
|
||||||
self.hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
self.hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
||||||
|
|
||||||
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
|
logger.info(f"Download Orchestrator initialized - Mode: {self.mode}")
|
||||||
if self.mode == 'hybrid':
|
if self.mode == 'hybrid':
|
||||||
if self.hybrid_order:
|
if self.hybrid_order:
|
||||||
logger.info(f" Source priority: {' → '.join(self.hybrid_order)}")
|
logger.info(f" Source priority: {' → '.join(self.hybrid_order)}")
|
||||||
|
|
@ -71,7 +71,7 @@ class DownloadOrchestrator:
|
||||||
try:
|
try:
|
||||||
return cls()
|
return cls()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ {name} download client failed to initialize: {e}")
|
logger.error(f"{name} download client failed to initialize: {e}")
|
||||||
self._init_failures.append(name)
|
self._init_failures.append(name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ class DownloadOrchestrator:
|
||||||
# Reload underlying client configs (SLSKD URL, API key, etc.)
|
# Reload underlying client configs (SLSKD URL, API key, etc.)
|
||||||
if self.soulseek:
|
if self.soulseek:
|
||||||
self.soulseek._setup_client()
|
self.soulseek._setup_client()
|
||||||
logger.info(f"🔄 Soulseek client config reloaded")
|
logger.info(f"Soulseek client config reloaded")
|
||||||
|
|
||||||
# Reconnect Deezer if ARL changed
|
# Reconnect Deezer if ARL changed
|
||||||
deezer_arl = config_manager.get('deezer_download.arl', '')
|
deezer_arl = config_manager.get('deezer_download.arl', '')
|
||||||
|
|
@ -93,7 +93,18 @@ class DownloadOrchestrator:
|
||||||
self.deezer_dl.reconnect(deezer_arl)
|
self.deezer_dl.reconnect(deezer_arl)
|
||||||
self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||||
|
|
||||||
logger.info(f"🔄 Download Orchestrator settings reloaded - Mode: {self.mode}")
|
# Reload download path for all clients that cache it
|
||||||
|
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
|
for client in [self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl]:
|
||||||
|
if client and hasattr(client, 'download_path') and client.download_path != new_path:
|
||||||
|
client.download_path = new_path
|
||||||
|
client.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
# YouTube also caches path in yt-dlp opts
|
||||||
|
if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts:
|
||||||
|
client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s')
|
||||||
|
logger.info(f"{type(client).__name__} download path updated to: {new_path}")
|
||||||
|
|
||||||
|
logger.info(f"Download Orchestrator settings reloaded - Mode: {self.mode}")
|
||||||
|
|
||||||
def _client(self, name):
|
def _client(self, name):
|
||||||
"""Get a client by name, returning None if not initialized."""
|
"""Get a client by name, returning None if not initialized."""
|
||||||
|
|
@ -143,7 +154,7 @@ class DownloadOrchestrator:
|
||||||
except Exception:
|
except Exception:
|
||||||
results[source] = False
|
results[source] = False
|
||||||
|
|
||||||
status_parts = [f"{s}: {'✅' if ok else '❌'}" for s, ok in results.items()]
|
status_parts = [f"{s}: {'' if ok else ''}" for s, ok in results.items()]
|
||||||
logger.info(f" {' | '.join(status_parts)}")
|
logger.info(f" {' | '.join(status_parts)}")
|
||||||
|
|
||||||
return any(results.values())
|
return any(results.values())
|
||||||
|
|
@ -168,9 +179,9 @@ class DownloadOrchestrator:
|
||||||
if self.mode != 'hybrid':
|
if self.mode != 'hybrid':
|
||||||
client = self._client(self.mode)
|
client = self._client(self.mode)
|
||||||
if not client:
|
if not client:
|
||||||
logger.error(f"❌ {source_names.get(self.mode, self.mode)} client not available (failed to initialize)")
|
logger.error(f"{source_names.get(self.mode, self.mode)} client not available (failed to initialize)")
|
||||||
return [], []
|
return [], []
|
||||||
logger.info(f"🔍 Searching {source_names.get(self.mode, self.mode)}: {query}")
|
logger.info(f"Searching {source_names.get(self.mode, self.mode)}: {query}")
|
||||||
return await client.search(query, timeout, progress_callback)
|
return await client.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
elif self.mode == 'hybrid':
|
elif self.mode == 'hybrid':
|
||||||
|
|
@ -189,33 +200,33 @@ class DownloadOrchestrator:
|
||||||
if not source_order:
|
if not source_order:
|
||||||
source_order = ['soulseek']
|
source_order = ['soulseek']
|
||||||
|
|
||||||
logger.info(f"🔍 Hybrid search ({' → '.join(source_order)}): {query}")
|
logger.info(f"Hybrid search ({' → '.join(source_order)}): {query}")
|
||||||
|
|
||||||
# Try each source in priority order (skip unconfigured/unavailable ones)
|
# Try each source in priority order (skip unconfigured/unavailable ones)
|
||||||
for i, source_name in enumerate(source_order):
|
for i, source_name in enumerate(source_order):
|
||||||
client = clients.get(source_name)
|
client = clients.get(source_name)
|
||||||
if not client:
|
if not client:
|
||||||
logger.info(f"⏭️ Skipping {source_name} (not available)")
|
logger.info(f"Skipping {source_name} (not available)")
|
||||||
continue
|
continue
|
||||||
if hasattr(client, 'is_configured') and not client.is_configured():
|
if hasattr(client, 'is_configured') and not client.is_configured():
|
||||||
logger.info(f"⏭️ Skipping {source_name} (not configured)")
|
logger.info(f"Skipping {source_name} (not configured)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if i == 0:
|
if i == 0:
|
||||||
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}")
|
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"🔄 Trying {source_name} (priority {i+1}): {query}")
|
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||||
|
|
||||||
tracks, albums = await client.search(query, timeout, progress_callback)
|
tracks, albums = await client.search(query, timeout, progress_callback)
|
||||||
if tracks:
|
if tracks:
|
||||||
logger.info(f"✅ {source_name} found {len(tracks)} tracks")
|
logger.info(f"{source_name} found {len(tracks)} tracks")
|
||||||
return (tracks, albums)
|
return (tracks, albums)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ {source_name} search failed: {e}")
|
logger.warning(f"{source_name} search failed: {e}")
|
||||||
|
|
||||||
# Nothing found from any source
|
# Nothing found from any source
|
||||||
logger.warning(f"❌ Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
|
logger.warning(f"Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
||||||
# Fallback: empty results
|
# Fallback: empty results
|
||||||
|
|
@ -289,10 +300,10 @@ class DownloadOrchestrator:
|
||||||
if scored:
|
if scored:
|
||||||
scored.sort(key=lambda x: x._match_confidence, reverse=True)
|
scored.sort(key=lambda x: x._match_confidence, reverse=True)
|
||||||
filtered_results = scored
|
filtered_results = scored
|
||||||
logger.info(f"🎵 Streaming validation: {len(scored)}/{len(tracks)} passed "
|
logger.info(f"Streaming validation: {len(scored)}/{len(tracks)} passed "
|
||||||
f"(best: {scored[0]._match_confidence:.2f})")
|
f"(best: {scored[0]._match_confidence:.2f})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ No streaming results passed validation for: {query}")
|
logger.warning(f"No streaming results passed validation for: {query}")
|
||||||
return None
|
return None
|
||||||
elif is_streaming:
|
elif is_streaming:
|
||||||
filtered_results = tracks
|
filtered_results = tracks
|
||||||
|
|
@ -337,12 +348,12 @@ class DownloadOrchestrator:
|
||||||
client = source_map[username]
|
client = source_map[username]
|
||||||
if not client:
|
if not client:
|
||||||
raise RuntimeError(f"{source_names[username]} download client not available (failed to initialize)")
|
raise RuntimeError(f"{source_names[username]} download client not available (failed to initialize)")
|
||||||
logger.info(f"📥 Downloading from {source_names[username]}: {filename}")
|
logger.info(f"Downloading from {source_names[username]}: {filename}")
|
||||||
return await client.download(username, filename, file_size)
|
return await client.download(username, filename, file_size)
|
||||||
else:
|
else:
|
||||||
if not self.soulseek:
|
if not self.soulseek:
|
||||||
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
||||||
logger.info(f"📥 Downloading from Soulseek: {filename}")
|
logger.info(f"Downloading from Soulseek: {filename}")
|
||||||
return await self.soulseek.download(username, filename, file_size)
|
return await self.soulseek.download(username, filename, file_size)
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
|
|
|
||||||
|
|
@ -1053,7 +1053,7 @@ class iTunesClient:
|
||||||
logger.debug(f"Replacing clean version with explicit: {album.name} (verified {track_count} tracks)")
|
logger.debug(f"Replacing clean version with explicit: {album.name} (verified {track_count} tracks)")
|
||||||
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version")
|
logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version")
|
||||||
else:
|
else:
|
||||||
|
|
@ -1072,7 +1072,7 @@ class iTunesClient:
|
||||||
logger.debug(f" Verified explicit album has {track_count} tracks")
|
logger.debug(f" Verified explicit album has {track_count} tracks")
|
||||||
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
||||||
# Don't add to seen_albums so a clean version can be added later
|
# Don't add to seen_albums so a clean version can be added later
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to validate explicit album {album.name}: {e}, skipping")
|
logger.warning(f"Failed to validate explicit album {album.name}: {e}, skipping")
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ class JellyfinClient:
|
||||||
self.music_library_id = None
|
self.music_library_id = None
|
||||||
self._connection_attempted = False
|
self._connection_attempted = False
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
logger.info("🔄 Jellyfin client config reset — will reconnect with new settings")
|
logger.info("Jellyfin client config reset — will reconnect with new settings")
|
||||||
|
|
||||||
def ensure_connection(self) -> bool:
|
def ensure_connection(self) -> bool:
|
||||||
"""Ensure connection to Jellyfin server with lazy initialization."""
|
"""Ensure connection to Jellyfin server with lazy initialization."""
|
||||||
|
|
@ -493,17 +493,17 @@ class JellyfinClient:
|
||||||
|
|
||||||
# Check if we're in metadata-only mode and skip expensive operations
|
# Check if we're in metadata-only mode and skip expensive operations
|
||||||
if self._metadata_only_mode:
|
if self._metadata_only_mode:
|
||||||
logger.info("🎯 Skipping cache population for metadata-only operation")
|
logger.info("Skipping cache population for metadata-only operation")
|
||||||
self._cache_populated = True
|
self._cache_populated = True
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("🚀 Starting aggressive Jellyfin cache population to eliminate slow individual API calls...")
|
logger.info("Starting aggressive Jellyfin cache population to eliminate slow individual API calls...")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback("Fetching all tracks in bulk...")
|
self._progress_callback("Fetching all tracks in bulk...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
|
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
|
||||||
logger.info("🎵 Fetching all tracks in bulk...")
|
logger.info("Fetching all tracks in bulk...")
|
||||||
all_tracks = []
|
all_tracks = []
|
||||||
start_index = 0
|
start_index = 0
|
||||||
limit = 10000
|
limit = 10000
|
||||||
|
|
@ -530,13 +530,13 @@ class JellyfinClient:
|
||||||
if limit > 1000:
|
if limit > 1000:
|
||||||
limit = limit // 2
|
limit = limit // 2
|
||||||
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
||||||
logger.warning(f"⚠️ Track fetch failed - reducing batch size to {limit}")
|
logger.warning(f"Track fetch failed - reducing batch size to {limit}")
|
||||||
continue
|
continue
|
||||||
elif consecutive_failures >= 2:
|
elif consecutive_failures >= 2:
|
||||||
logger.warning("🚨 Multiple track fetch failures at minimum batch size - stopping")
|
logger.warning("Multiple track fetch failures at minimum batch size - stopping")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ Track fetch failed at minimum batch size - retrying once")
|
logger.warning("Track fetch failed at minimum batch size - retrying once")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
|
|
@ -551,7 +551,7 @@ class JellyfinClient:
|
||||||
|
|
||||||
start_index += limit
|
start_index += limit
|
||||||
progress_msg = f"Fetched {len(all_tracks)} tracks so far..."
|
progress_msg = f"Fetched {len(all_tracks)} tracks so far..."
|
||||||
logger.info(f" 🎵 {progress_msg} (batch size: {limit})")
|
logger.info(f" {progress_msg} (batch size: {limit})")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback(progress_msg)
|
self._progress_callback(progress_msg)
|
||||||
|
|
||||||
|
|
@ -564,12 +564,12 @@ class JellyfinClient:
|
||||||
self._track_cache[album_id] = []
|
self._track_cache[album_id] = []
|
||||||
self._track_cache[album_id].append(JellyfinTrack(track_data, self))
|
self._track_cache[album_id].append(JellyfinTrack(track_data, self))
|
||||||
|
|
||||||
logger.info(f"✅ Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums")
|
logger.info(f"Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...")
|
self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...")
|
||||||
|
|
||||||
# STEP 2: Fetch all albums in bulk (same proven pattern)
|
# STEP 2: Fetch all albums in bulk (same proven pattern)
|
||||||
logger.info("📀 Fetching all albums in bulk...")
|
logger.info("Fetching all albums in bulk...")
|
||||||
all_albums = []
|
all_albums = []
|
||||||
start_index = 0
|
start_index = 0
|
||||||
limit = 10000
|
limit = 10000
|
||||||
|
|
@ -596,13 +596,13 @@ class JellyfinClient:
|
||||||
if limit > 1000:
|
if limit > 1000:
|
||||||
limit = limit // 2
|
limit = limit // 2
|
||||||
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
||||||
logger.warning(f"⚠️ Album fetch failed - reducing batch size to {limit}")
|
logger.warning(f"Album fetch failed - reducing batch size to {limit}")
|
||||||
continue
|
continue
|
||||||
elif consecutive_failures >= 2:
|
elif consecutive_failures >= 2:
|
||||||
logger.warning("🚨 Multiple album fetch failures at minimum batch size - stopping")
|
logger.warning("Multiple album fetch failures at minimum batch size - stopping")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ Album fetch failed at minimum batch size - retrying once")
|
logger.warning("Album fetch failed at minimum batch size - retrying once")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
|
|
@ -617,7 +617,7 @@ class JellyfinClient:
|
||||||
|
|
||||||
start_index += limit
|
start_index += limit
|
||||||
progress_msg = f"Fetched {len(all_albums)} albums so far..."
|
progress_msg = f"Fetched {len(all_albums)} albums so far..."
|
||||||
logger.info(f" 📀 {progress_msg} (batch size: {limit})")
|
logger.info(f" {progress_msg} (batch size: {limit})")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback(progress_msg)
|
self._progress_callback(progress_msg)
|
||||||
|
|
||||||
|
|
@ -632,10 +632,10 @@ class JellyfinClient:
|
||||||
self._album_cache[artist_id] = []
|
self._album_cache[artist_id] = []
|
||||||
self._album_cache[artist_id].append(JellyfinAlbum(album_data, self))
|
self._album_cache[artist_id].append(JellyfinAlbum(album_data, self))
|
||||||
|
|
||||||
logger.info(f"✅ Cached {len(all_albums)} albums for {len(self._album_cache)} artists")
|
logger.info(f"Cached {len(all_albums)} albums for {len(self._album_cache)} artists")
|
||||||
|
|
||||||
self._cache_populated = True
|
self._cache_populated = True
|
||||||
logger.info("🎯 AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!")
|
logger.info("AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback("Cache complete! Now processing artists...")
|
self._progress_callback("Cache complete! Now processing artists...")
|
||||||
|
|
||||||
|
|
@ -648,7 +648,7 @@ class JellyfinClient:
|
||||||
if not albums:
|
if not albums:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🎯 Starting targeted Jellyfin cache for {len(albums)} recent albums...")
|
logger.info(f"Starting targeted Jellyfin cache for {len(albums)} recent albums...")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback(f"Caching tracks for {len(albums)} recent albums...")
|
self._progress_callback(f"Caching tracks for {len(albums)} recent albums...")
|
||||||
|
|
||||||
|
|
@ -688,11 +688,11 @@ class JellyfinClient:
|
||||||
# Progress update every 50 albums
|
# Progress update every 50 albums
|
||||||
if (i + 1) % 50 == 0 or i == len(album_ids) - 1:
|
if (i + 1) % 50 == 0 or i == len(album_ids) - 1:
|
||||||
progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..."
|
progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..."
|
||||||
logger.info(f" 🎯 {progress_msg}")
|
logger.info(f" {progress_msg}")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback(progress_msg)
|
self._progress_callback(progress_msg)
|
||||||
|
|
||||||
logger.info(f"✅ Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums")
|
logger.info(f"Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums")
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback("Targeted cache complete! Now checking for new tracks...")
|
self._progress_callback("Targeted cache complete! Now checking for new tracks...")
|
||||||
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ class JellyfinClient:
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
if result and 'Id' in result:
|
if result and 'Id' in result:
|
||||||
logger.info(f"✅ Created Jellyfin playlist '{name}' with {len(track_ids)} tracks")
|
logger.info(f"Created Jellyfin playlist '{name}' with {len(track_ids)} tracks")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned")
|
logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned")
|
||||||
|
|
@ -1407,7 +1407,7 @@ class JellyfinClient:
|
||||||
logger.error(f" Request params: Ids={add_params['Ids'][:200]}... (truncated)")
|
logger.error(f" Request params: Ids={add_params['Ids'][:200]}... (truncated)")
|
||||||
# Continue with other batches even if one fails
|
# Continue with other batches even if one fails
|
||||||
|
|
||||||
logger.info(f"✅ Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches")
|
logger.info(f"Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1453,7 +1453,7 @@ class JellyfinClient:
|
||||||
try:
|
try:
|
||||||
success = self.create_playlist(target_name, source_tracks)
|
success = self.create_playlist(target_name, source_tracks)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"✅ Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to create backup playlist '{target_name}'")
|
logger.error(f"Failed to create backup playlist '{target_name}'")
|
||||||
|
|
@ -1541,12 +1541,12 @@ class JellyfinClient:
|
||||||
|
|
||||||
if existing_playlist and create_backup:
|
if existing_playlist and create_backup:
|
||||||
backup_name = f"{playlist_name} Backup"
|
backup_name = f"{playlist_name} Backup"
|
||||||
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
logger.info(f"Creating backup playlist '{backup_name}' before sync")
|
||||||
|
|
||||||
if self.copy_playlist(playlist_name, backup_name):
|
if self.copy_playlist(playlist_name, backup_name):
|
||||||
logger.info(f"✅ Backup created successfully")
|
logger.info(f"Backup created successfully")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
logger.warning(f"Failed to create backup, continuing with sync")
|
||||||
|
|
||||||
if existing_playlist:
|
if existing_playlist:
|
||||||
# Delete existing playlist using DELETE request
|
# Delete existing playlist using DELETE request
|
||||||
|
|
@ -1612,7 +1612,7 @@ class JellyfinClient:
|
||||||
response = requests.post(url, headers=headers, params=params, timeout=10)
|
response = requests.post(url, headers=headers, params=params, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
logger.info(f"🎵 Triggered Jellyfin library scan for '{library_name}'")
|
logger.info(f"Triggered Jellyfin library scan for '{library_name}'")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1622,14 +1622,14 @@ class JellyfinClient:
|
||||||
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||||
"""Check if Jellyfin library is currently scanning"""
|
"""Check if Jellyfin library is currently scanning"""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
logger.debug("🔍 DEBUG: Not connected to Jellyfin, cannot check scan status")
|
logger.debug("DEBUG: Not connected to Jellyfin, cannot check scan status")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check scheduled tasks for library scan activities
|
# Check scheduled tasks for library scan activities
|
||||||
response = self._make_request('/ScheduledTasks')
|
response = self._make_request('/ScheduledTasks')
|
||||||
if not response:
|
if not response:
|
||||||
logger.debug("🔍 DEBUG: Could not get scheduled tasks")
|
logger.debug("DEBUG: Could not get scheduled tasks")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for task in response:
|
for task in response:
|
||||||
|
|
@ -1639,10 +1639,10 @@ class JellyfinClient:
|
||||||
# Look for library scan related tasks that are running
|
# Look for library scan related tasks that are running
|
||||||
if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name):
|
if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name):
|
||||||
if task_state in ['Running', 'Cancelling']:
|
if task_state in ['Running', 'Cancelling']:
|
||||||
logger.debug(f"🔍 DEBUG: Found running scan task: {task.get('Name')} (State: {task_state})")
|
logger.debug(f"DEBUG: Found running scan task: {task.get('Name')} (State: {task_state})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.debug("🔍 DEBUG: No active scan tasks detected")
|
logger.debug("DEBUG: No active scan tasks detected")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1802,9 +1802,9 @@ class JellyfinClient:
|
||||||
try:
|
try:
|
||||||
self._metadata_only_mode = enabled
|
self._metadata_only_mode = enabled
|
||||||
if enabled:
|
if enabled:
|
||||||
logger.info("🎯 Metadata-only mode enabled - will skip expensive track caching")
|
logger.info("Metadata-only mode enabled - will skip expensive track caching")
|
||||||
else:
|
else:
|
||||||
logger.info("🎯 Metadata-only mode disabled")
|
logger.info("Metadata-only mode disabled")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error setting metadata-only mode: {e}")
|
logger.error(f"Error setting metadata-only mode: {e}")
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ class ListenBrainzClient:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if data.get('valid'):
|
if data.get('valid'):
|
||||||
self.username = data.get('user_name')
|
self.username = data.get('user_name')
|
||||||
logger.info(f"✅ ListenBrainz authenticated as: {self.username}")
|
logger.info(f"ListenBrainz authenticated as: {self.username}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.warning("❌ Invalid ListenBrainz token")
|
logger.warning("Invalid ListenBrainz token")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error validating ListenBrainz token: {e}")
|
logger.error(f"Error validating ListenBrainz token: {e}")
|
||||||
|
|
@ -179,7 +179,7 @@ class ListenBrainzClient:
|
||||||
if response and response.status_code == 200:
|
if response and response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
playlists = data.get('playlists', [])
|
playlists = data.get('playlists', [])
|
||||||
logger.info(f"📋 Fetched {len(playlists)} playlists created for {self.username}")
|
logger.info(f"Fetched {len(playlists)} playlists created for {self.username}")
|
||||||
return playlists
|
return playlists
|
||||||
elif response and response.status_code == 404:
|
elif response and response.status_code == 404:
|
||||||
logger.warning(f"User {self.username} not found")
|
logger.warning(f"User {self.username} not found")
|
||||||
|
|
@ -215,7 +215,7 @@ class ListenBrainzClient:
|
||||||
if response and response.status_code == 200:
|
if response and response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
playlists = data.get('playlists', [])
|
playlists = data.get('playlists', [])
|
||||||
logger.info(f"📋 Fetched {len(playlists)} user playlists for {self.username}")
|
logger.info(f"Fetched {len(playlists)} user playlists for {self.username}")
|
||||||
return playlists
|
return playlists
|
||||||
elif response and response.status_code == 404:
|
elif response and response.status_code == 404:
|
||||||
logger.warning(f"User {self.username} not found")
|
logger.warning(f"User {self.username} not found")
|
||||||
|
|
@ -251,7 +251,7 @@ class ListenBrainzClient:
|
||||||
if response and response.status_code == 200:
|
if response and response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
playlists = data.get('playlists', [])
|
playlists = data.get('playlists', [])
|
||||||
logger.info(f"📋 Fetched {len(playlists)} collaborative playlists for {self.username}")
|
logger.info(f"Fetched {len(playlists)} collaborative playlists for {self.username}")
|
||||||
return playlists
|
return playlists
|
||||||
elif response and response.status_code == 404:
|
elif response and response.status_code == 404:
|
||||||
logger.warning(f"User {self.username} not found")
|
logger.warning(f"User {self.username} not found")
|
||||||
|
|
@ -291,7 +291,7 @@ class ListenBrainzClient:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
playlist = data.get('playlist', {})
|
playlist = data.get('playlist', {})
|
||||||
track_count = len(playlist.get('track', []))
|
track_count = len(playlist.get('track', []))
|
||||||
logger.info(f"📋 Fetched playlist '{playlist.get('title')}' with {track_count} tracks")
|
logger.info(f"Fetched playlist '{playlist.get('title')}' with {track_count} tracks")
|
||||||
return playlist
|
return playlist
|
||||||
elif response and response.status_code == 404:
|
elif response and response.status_code == 404:
|
||||||
logger.warning(f"Playlist {playlist_mbid} not found")
|
logger.warning(f"Playlist {playlist_mbid} not found")
|
||||||
|
|
@ -333,7 +333,7 @@ class ListenBrainzClient:
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
playlists = data.get('playlists', [])
|
playlists = data.get('playlists', [])
|
||||||
logger.info(f"🔍 Found {len(playlists)} playlists matching '{query}'")
|
logger.info(f"Found {len(playlists)} playlists matching '{query}'")
|
||||||
return playlists
|
return playlists
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to search playlists: {response.status_code}")
|
logger.error(f"Failed to search playlists: {response.status_code}")
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class ListenBrainzManager:
|
||||||
"error": "Not authenticated"
|
"error": "Not authenticated"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("🔄 Starting ListenBrainz playlists update...")
|
logger.info("Starting ListenBrainz playlists update...")
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
"created_for": {"updated": 0, "skipped": 0, "new": 0},
|
"created_for": {"updated": 0, "skipped": 0, "new": 0},
|
||||||
|
|
@ -97,7 +97,7 @@ class ListenBrainzManager:
|
||||||
for playlist_type, fetch_func in playlist_types:
|
for playlist_type, fetch_func in playlist_types:
|
||||||
try:
|
try:
|
||||||
playlists = fetch_func()
|
playlists = fetch_func()
|
||||||
logger.info(f"📋 Fetched {len(playlists)} {playlist_type} playlists")
|
logger.info(f"Fetched {len(playlists)} {playlist_type} playlists")
|
||||||
|
|
||||||
for playlist in playlists:
|
for playlist in playlists:
|
||||||
result = self._update_playlist(playlist, playlist_type)
|
result = self._update_playlist(playlist, playlist_type)
|
||||||
|
|
@ -114,7 +114,7 @@ class ListenBrainzManager:
|
||||||
# Cleanup old playlists (keep only 4 most recent per type)
|
# Cleanup old playlists (keep only 4 most recent per type)
|
||||||
self._cleanup_old_playlists()
|
self._cleanup_old_playlists()
|
||||||
|
|
||||||
logger.info(f"✅ ListenBrainz update complete: {summary}")
|
logger.info(f"ListenBrainz update complete: {summary}")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"summary": summary
|
"summary": summary
|
||||||
|
|
@ -165,11 +165,11 @@ class ListenBrainzManager:
|
||||||
|
|
||||||
# Skip if track count hasn't changed (playlist content likely the same)
|
# Skip if track count hasn't changed (playlist content likely the same)
|
||||||
if db_track_count == track_count:
|
if db_track_count == track_count:
|
||||||
logger.debug(f"✓ Playlist '{title}' unchanged, skipping")
|
logger.debug(f"Playlist '{title}' unchanged, skipping")
|
||||||
conn.close()
|
conn.close()
|
||||||
return "skipped"
|
return "skipped"
|
||||||
|
|
||||||
logger.info(f"🔄 Playlist '{title}' changed ({db_track_count} → {track_count} tracks), updating...")
|
logger.info(f"Playlist '{title}' changed ({db_track_count} → {track_count} tracks), updating...")
|
||||||
|
|
||||||
# Delete old tracks
|
# Delete old tracks
|
||||||
cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (db_id,))
|
cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (db_id,))
|
||||||
|
|
@ -185,7 +185,7 @@ class ListenBrainzManager:
|
||||||
result_type = "updated"
|
result_type = "updated"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"➕ New playlist '{title}', adding to database...")
|
logger.info(f"New playlist '{title}', adding to database...")
|
||||||
|
|
||||||
# Insert new playlist
|
# Insert new playlist
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|
@ -218,7 +218,7 @@ class ListenBrainzManager:
|
||||||
"""
|
"""
|
||||||
Cache tracks for a playlist, including fetching cover art URLs in parallel
|
Cache tracks for a playlist, including fetching cover art URLs in parallel
|
||||||
"""
|
"""
|
||||||
logger.info(f"🎵 Caching {len(tracks)} tracks with cover art...")
|
logger.info(f"Caching {len(tracks)} tracks with cover art...")
|
||||||
|
|
||||||
# First pass: extract track data
|
# First pass: extract track data
|
||||||
track_data_list = []
|
track_data_list = []
|
||||||
|
|
@ -324,7 +324,7 @@ class ListenBrainzManager:
|
||||||
logger.debug(f"Error fetching cover for track {idx}: {e}")
|
logger.debug(f"Error fetching cover for track {idx}: {e}")
|
||||||
|
|
||||||
covers_found = sum(1 for t in track_data_list if t.get('album_cover_url'))
|
covers_found = sum(1 for t in track_data_list if t.get('album_cover_url'))
|
||||||
logger.info(f"✅ Fetched {covers_found}/{len(track_data_list)} cover art URLs")
|
logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs")
|
||||||
|
|
||||||
def _cleanup_old_playlists(self):
|
def _cleanup_old_playlists(self):
|
||||||
"""Remove old playlists, keeping only the 25 most recent per type"""
|
"""Remove old playlists, keeping only the 25 most recent per type"""
|
||||||
|
|
@ -354,7 +354,7 @@ class ListenBrainzManager:
|
||||||
# Delete old playlists
|
# Delete old playlists
|
||||||
cursor.execute(f"DELETE FROM listenbrainz_playlists WHERE id IN ({placeholders})", old_playlist_ids)
|
cursor.execute(f"DELETE FROM listenbrainz_playlists WHERE id IN ({placeholders})", old_playlist_ids)
|
||||||
|
|
||||||
logger.info(f"🗑️ Removed {len(old_playlist_ids)} old {playlist_type} playlists")
|
logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up {playlist_type} playlists: {e}")
|
logger.error(f"Error cleaning up {playlist_type} playlists: {e}")
|
||||||
|
|
|
||||||
|
|
@ -113,14 +113,14 @@ class LyricsClient:
|
||||||
f.write(synced)
|
f.write(synced)
|
||||||
# Embed synced lyrics in audio tags
|
# Embed synced lyrics in audio tags
|
||||||
self._embed_lyrics(audio_file_path, synced)
|
self._embed_lyrics(audio_file_path, synced)
|
||||||
logger.info(f"✅ Created synced LRC + embedded: {os.path.basename(lrc_path)}")
|
logger.info(f"Created synced LRC + embedded: {os.path.basename(lrc_path)}")
|
||||||
else:
|
else:
|
||||||
# Plain lyrics only → write as .txt (not .lrc, which requires timestamps)
|
# Plain lyrics only → write as .txt (not .lrc, which requires timestamps)
|
||||||
with open(txt_path, 'w', encoding='utf-8') as f:
|
with open(txt_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(plain)
|
f.write(plain)
|
||||||
# Still embed plain lyrics in audio tags (players can display unsynced lyrics)
|
# Still embed plain lyrics in audio tags (players can display unsynced lyrics)
|
||||||
self._embed_lyrics(audio_file_path, plain)
|
self._embed_lyrics(audio_file_path, plain)
|
||||||
logger.info(f"✅ Created plain lyrics .txt + embedded: {os.path.basename(txt_path)}")
|
logger.info(f"Created plain lyrics .txt + embedded: {os.path.basename(txt_path)}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,7 @@ class MusicMatchingEngine:
|
||||||
|
|
||||||
if is_likely_album and 4 <= len(potential_album_part) <= 30:
|
if is_likely_album and 4 <= len(potential_album_part) <= 30:
|
||||||
cleaned_title = re.sub(dash_pattern, '', track_title).strip()
|
cleaned_title = re.sub(dash_pattern, '', track_title).strip()
|
||||||
print(f"🎵 Heuristic album detection: '{original_title}' → '{cleaned_title}' (removed: '{potential_album_part}')")
|
print(f"Heuristic album detection: '{original_title}' → '{cleaned_title}' (removed: '{potential_album_part}')")
|
||||||
return cleaned_title, True
|
return cleaned_title, True
|
||||||
|
|
||||||
return track_title, False
|
return track_title, False
|
||||||
|
|
@ -750,7 +750,7 @@ class MusicMatchingEngine:
|
||||||
f"vs '{slskd_track.filename[:60]}...' | "
|
f"vs '{slskd_track.filename[:60]}...' | "
|
||||||
f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), "
|
f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), "
|
||||||
f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}{album_tag}, "
|
f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}{album_tag}, "
|
||||||
f"Final: {final_confidence:.2f} {'✅ PASS' if final_confidence > 0.63 else '❌ FAIL'}"
|
f"Final: {final_confidence:.2f} {'PASS' if final_confidence > 0.63 else 'FAIL'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure the final score doesn't exceed 1.0
|
# Ensure the final score doesn't exceed 1.0
|
||||||
|
|
@ -982,11 +982,11 @@ class MusicMatchingEngine:
|
||||||
|
|
||||||
# Debug logging for troubleshooting
|
# Debug logging for troubleshooting
|
||||||
if scored_results and not confident_results:
|
if scored_results and not confident_results:
|
||||||
print(f"⚠️ DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.58")
|
print(f"DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.58")
|
||||||
for i, result in enumerate(sorted_results[:3]): # Show top 3
|
for i, result in enumerate(sorted_results[:3]): # Show top 3
|
||||||
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||||
elif confident_results:
|
elif confident_results:
|
||||||
print(f"✅ DEBUG: {len(confident_results)} results passed confidence threshold 0.58")
|
print(f"DEBUG: {len(confident_results)} results passed confidence threshold 0.58")
|
||||||
for i, result in enumerate(confident_results[:3]): # Show top 3
|
for i, result in enumerate(confident_results[:3]): # Show top 3
|
||||||
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,15 +122,15 @@ class MediaScanManager:
|
||||||
if self._scan_in_progress:
|
if self._scan_in_progress:
|
||||||
# Server is currently scanning - mark that we need another scan later
|
# Server is currently scanning - mark that we need another scan later
|
||||||
self._downloads_during_scan = True
|
self._downloads_during_scan = True
|
||||||
logger.info(f"📡 Media scan in progress - queueing follow-up scan ({reason})")
|
logger.info(f"Media scan in progress - queueing follow-up scan ({reason})")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Cancel any existing timer and start a new one
|
# Cancel any existing timer and start a new one
|
||||||
if self._timer:
|
if self._timer:
|
||||||
self._timer.cancel()
|
self._timer.cancel()
|
||||||
logger.debug(f"⏳ Resetting scan timer ({reason})")
|
logger.debug(f"Resetting scan timer ({reason})")
|
||||||
else:
|
else:
|
||||||
logger.info(f"⏳ Media scan queued - will execute in {self.delay}s ({reason})")
|
logger.info(f"Media scan queued - will execute in {self.delay}s ({reason})")
|
||||||
|
|
||||||
# Start the debounce timer
|
# Start the debounce timer
|
||||||
self._timer = threading.Timer(self.delay, self._execute_scan)
|
self._timer = threading.Timer(self.delay, self._execute_scan)
|
||||||
|
|
@ -176,21 +176,21 @@ class MediaScanManager:
|
||||||
# Get the active media client
|
# Get the active media client
|
||||||
media_client, server_type = self._get_active_media_client()
|
media_client, server_type = self._get_active_media_client()
|
||||||
if not media_client:
|
if not media_client:
|
||||||
logger.error("❌ No active media client available for library scan")
|
logger.error("No active media client available for library scan")
|
||||||
self._reset_scan_state()
|
self._reset_scan_state()
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🎵 Starting {server_type.upper()} library scan...")
|
logger.info(f"Starting {server_type.upper()} library scan...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = media_client.trigger_library_scan()
|
success = media_client.trigger_library_scan()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"✅ {server_type.upper()} library scan initiated successfully")
|
logger.info(f"{server_type.upper()} library scan initiated successfully")
|
||||||
# Start new periodic update system instead of completion detection
|
# Start new periodic update system instead of completion detection
|
||||||
self._start_periodic_updates()
|
self._start_periodic_updates()
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Failed to initiate {server_type.upper()} library scan")
|
logger.error(f"Failed to initiate {server_type.upper()} library scan")
|
||||||
self._reset_scan_state()
|
self._reset_scan_state()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -207,7 +207,7 @@ class MediaScanManager:
|
||||||
|
|
||||||
self._is_doing_periodic_updates = True
|
self._is_doing_periodic_updates = True
|
||||||
|
|
||||||
logger.info(f"🕒 Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
||||||
|
|
||||||
# Schedule first periodic update after 5 minutes
|
# Schedule first periodic update after 5 minutes
|
||||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||||
|
|
@ -242,20 +242,20 @@ class MediaScanManager:
|
||||||
is_scanning = media_client.is_library_scanning("Music")
|
is_scanning = media_client.is_library_scanning("Music")
|
||||||
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
||||||
|
|
||||||
logger.info(f"🕒 PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}")
|
logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}")
|
||||||
|
|
||||||
if is_scanning:
|
if is_scanning:
|
||||||
# Still scanning - trigger database update and continue periodic updates
|
# Still scanning - trigger database update and continue periodic updates
|
||||||
logger.info(f"🔄 {server_type.upper()} still scanning - triggering database update")
|
logger.info(f"{server_type.upper()} still scanning - triggering database update")
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
|
|
||||||
# Schedule next periodic update
|
# Schedule next periodic update
|
||||||
logger.info(f"🕒 Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
||||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||||
self._periodic_update_timer.start()
|
self._periodic_update_timer.start()
|
||||||
else:
|
else:
|
||||||
# Scanning stopped - final update and cleanup
|
# Scanning stopped - final update and cleanup
|
||||||
logger.info(f"✅ {server_type.upper()} scanning completed - doing final database update")
|
logger.info(f"{server_type.upper()} scanning completed - doing final database update")
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
self._stop_periodic_updates()
|
self._stop_periodic_updates()
|
||||||
|
|
||||||
|
|
@ -273,7 +273,7 @@ class MediaScanManager:
|
||||||
self._periodic_update_timer.cancel()
|
self._periodic_update_timer.cancel()
|
||||||
self._periodic_update_timer = None
|
self._periodic_update_timer = None
|
||||||
|
|
||||||
logger.info("🕒 Stopped periodic database updates")
|
logger.info("Stopped periodic database updates")
|
||||||
self._scan_completed()
|
self._scan_completed()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -292,17 +292,17 @@ class MediaScanManager:
|
||||||
logger.debug("Scan completion callback called but scan was not in progress")
|
logger.debug("Scan completion callback called but scan was not in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("📡 Media library scan completed")
|
logger.info("Media library scan completed")
|
||||||
|
|
||||||
# Call registered completion callbacks
|
# Call registered completion callbacks
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
|
|
||||||
# Check if we need a follow-up scan
|
# Check if we need a follow-up scan
|
||||||
if downloads_during_scan:
|
if downloads_during_scan:
|
||||||
logger.info("🔄 Downloads occurred during scan - triggering follow-up scan")
|
logger.info("Downloads occurred during scan - triggering follow-up scan")
|
||||||
self.request_scan("Follow-up scan for downloads during previous scan")
|
self.request_scan("Follow-up scan for downloads during previous scan")
|
||||||
else:
|
else:
|
||||||
logger.info("✅ No downloads during scan - scan cycle complete")
|
logger.info("No downloads during scan - scan cycle complete")
|
||||||
|
|
||||||
def _call_completion_callbacks(self):
|
def _call_completion_callbacks(self):
|
||||||
"""Call all registered scan completion callbacks"""
|
"""Call all registered scan completion callbacks"""
|
||||||
|
|
@ -344,7 +344,7 @@ class MediaScanManager:
|
||||||
logger.warning("Force scan requested but scan already in progress")
|
logger.warning("Force scan requested but scan already in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("🚀 Force scan requested - executing immediately")
|
logger.info("Force scan requested - executing immediately")
|
||||||
self._execute_scan()
|
self._execute_scan()
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ the logic. This prevents bugs where different files have different defaults
|
||||||
or auth checks.
|
or auth checks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
from typing import List, Optional, Dict, Any, Literal
|
from typing import List, Optional, Dict, Any, Literal
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient
|
||||||
from core.itunes_client import iTunesClient
|
from core.itunes_client import iTunesClient
|
||||||
|
|
@ -16,6 +17,9 @@ logger = get_logger("metadata_service")
|
||||||
|
|
||||||
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
||||||
|
|
||||||
|
_client_cache_lock = threading.RLock()
|
||||||
|
_client_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CANONICAL SOURCE SELECTION — all code should use these two functions
|
# CANONICAL SOURCE SELECTION — all code should use these two functions
|
||||||
|
|
@ -58,8 +62,89 @@ def get_primary_client():
|
||||||
|
|
||||||
This is THE single source of truth for "which client should I call?"
|
This is THE single source of truth for "which client should I call?"
|
||||||
"""
|
"""
|
||||||
source = get_primary_source()
|
return _get_client_for_source(get_primary_source())
|
||||||
|
|
||||||
|
|
||||||
|
def get_deezer_client():
|
||||||
|
"""Get cached Deezer client.
|
||||||
|
|
||||||
|
Deezer client is safe to reuse across requests because it owns no
|
||||||
|
request-specific state beyond the current access token.
|
||||||
|
"""
|
||||||
|
from core.deezer_client import DeezerClient
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
current_token = config_manager.get('deezer.access_token', None)
|
||||||
|
except Exception:
|
||||||
|
current_token = None
|
||||||
|
|
||||||
|
cache_key = f"deezer::{current_token or ''}"
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get(cache_key)
|
||||||
|
if client is None:
|
||||||
|
client = DeezerClient()
|
||||||
|
_client_cache[cache_key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_itunes_client():
|
||||||
|
"""Get cached iTunes client."""
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get("itunes")
|
||||||
|
if client is None:
|
||||||
|
client = iTunesClient()
|
||||||
|
_client_cache["itunes"] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_discogs_client(token: Optional[str] = None):
|
||||||
|
"""Get cached Discogs client.
|
||||||
|
|
||||||
|
Discogs auth changes are token-driven, so the cache key tracks the
|
||||||
|
current configured token.
|
||||||
|
"""
|
||||||
|
if token is None:
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
current_token = config_manager.get('discogs.token', '') or ''
|
||||||
|
except Exception:
|
||||||
|
current_token = ''
|
||||||
|
else:
|
||||||
|
current_token = token or ''
|
||||||
|
|
||||||
|
cache_key = f"discogs::{current_token}"
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get(cache_key)
|
||||||
|
if client is None:
|
||||||
|
from core.discogs_client import DiscogsClient
|
||||||
|
client = DiscogsClient(token=current_token or None)
|
||||||
|
_client_cache[cache_key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_hydrabase_client():
|
||||||
|
"""Return current Hydrabase client if connected, else iTunes fallback."""
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
ws = importlib.import_module('web_server')
|
||||||
|
client = getattr(ws, 'hydrabase_client', None)
|
||||||
|
if client and client.is_connected():
|
||||||
|
return client
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return get_itunes_client()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cached_metadata_clients():
|
||||||
|
"""Clear cached metadata clients.
|
||||||
|
|
||||||
|
Useful for tests and config reload flows.
|
||||||
|
"""
|
||||||
|
with _client_cache_lock:
|
||||||
|
_client_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_for_source(source: str):
|
||||||
if source == 'spotify':
|
if source == 'spotify':
|
||||||
try:
|
try:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
@ -69,38 +154,18 @@ def get_primary_client():
|
||||||
return sc
|
return sc
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Spotify selected but unavailable — fall back to Deezer
|
return get_deezer_client()
|
||||||
from core.deezer_client import DeezerClient
|
|
||||||
return DeezerClient()
|
|
||||||
|
|
||||||
if source == 'deezer':
|
if source == 'deezer':
|
||||||
from core.deezer_client import DeezerClient
|
return get_deezer_client()
|
||||||
return DeezerClient()
|
|
||||||
|
|
||||||
if source == 'discogs':
|
if source == 'discogs':
|
||||||
try:
|
return get_discogs_client()
|
||||||
from config.settings import config_manager
|
|
||||||
token = config_manager.get('discogs.token', '')
|
|
||||||
if token:
|
|
||||||
from core.discogs_client import DiscogsClient
|
|
||||||
return DiscogsClient(token=token)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
if source == 'hydrabase':
|
if source == 'hydrabase':
|
||||||
try:
|
return get_hydrabase_client()
|
||||||
import importlib
|
|
||||||
ws = importlib.import_module('web_server')
|
|
||||||
client = getattr(ws, 'hydrabase_client', None)
|
|
||||||
if client and client.is_connected():
|
|
||||||
return client
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
# Default: iTunes
|
return get_itunes_client()
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -141,14 +206,14 @@ class MetadataService:
|
||||||
self.preferred_provider = preferred_provider
|
self.preferred_provider = preferred_provider
|
||||||
self.spotify = SpotifyClient()
|
self.spotify = SpotifyClient()
|
||||||
self._fallback_source = get_primary_source()
|
self._fallback_source = get_primary_source()
|
||||||
self.itunes = get_primary_client() # May be iTunesClient or DeezerClient
|
self.itunes = _get_client_for_source(self._fallback_source)
|
||||||
|
|
||||||
self._log_initialization()
|
self._log_initialization()
|
||||||
|
|
||||||
def _log_initialization(self):
|
def _log_initialization(self):
|
||||||
"""Log initialization status"""
|
"""Log initialization status"""
|
||||||
spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated"
|
spotify_status = "Authenticated" if self.spotify.is_spotify_authenticated() else "Not authenticated"
|
||||||
fallback_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
|
fallback_status = "Available" if self.itunes.is_authenticated() else "Not available"
|
||||||
|
|
||||||
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}")
|
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}")
|
||||||
logger.info(f"Preferred provider: {self.preferred_provider}")
|
logger.info(f"Preferred provider: {self.preferred_provider}")
|
||||||
|
|
@ -311,13 +376,9 @@ class MetadataService:
|
||||||
"""Reload configuration for both clients"""
|
"""Reload configuration for both clients"""
|
||||||
logger.info("Reloading metadata service configuration")
|
logger.info("Reloading metadata service configuration")
|
||||||
self.spotify.reload_config()
|
self.spotify.reload_config()
|
||||||
# Re-create fallback client in case the setting changed
|
|
||||||
new_source = get_primary_source()
|
new_source = get_primary_source()
|
||||||
if new_source != self._fallback_source:
|
self._fallback_source = new_source
|
||||||
self._fallback_source = new_source
|
self.itunes = _get_client_for_source(new_source)
|
||||||
self.itunes = get_primary_client()
|
|
||||||
elif hasattr(self.itunes, 'reload_config'):
|
|
||||||
self.itunes.reload_config()
|
|
||||||
self._log_initialization()
|
self._log_initialization()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,11 +288,11 @@ class MusicBrainzWorker:
|
||||||
if result and result.get('mbid'):
|
if result and result.get('mbid'):
|
||||||
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
|
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"✅ Matched artist '{item_name}' → MBID: {result['mbid']}")
|
logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}")
|
||||||
else:
|
else:
|
||||||
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
|
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"❌ No match for artist '{item_name}'")
|
logger.debug(f"No match for artist '{item_name}'")
|
||||||
|
|
||||||
elif item_type == 'album':
|
elif item_type == 'album':
|
||||||
artist_name = item.get('artist')
|
artist_name = item.get('artist')
|
||||||
|
|
@ -300,11 +300,11 @@ class MusicBrainzWorker:
|
||||||
if result and result.get('mbid'):
|
if result and result.get('mbid'):
|
||||||
self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched')
|
self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched')
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"✅ Matched album '{item_name}' → MBID: {result['mbid']}")
|
logger.info(f"Matched album '{item_name}' → MBID: {result['mbid']}")
|
||||||
else:
|
else:
|
||||||
self.mb_service.update_album_mbid(item_id, None, 'not_found')
|
self.mb_service.update_album_mbid(item_id, None, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"❌ No match for album '{item_name}'")
|
logger.debug(f"No match for album '{item_name}'")
|
||||||
|
|
||||||
elif item_type == 'track':
|
elif item_type == 'track':
|
||||||
artist_name = item.get('artist')
|
artist_name = item.get('artist')
|
||||||
|
|
@ -312,11 +312,11 @@ class MusicBrainzWorker:
|
||||||
if result and result.get('mbid'):
|
if result and result.get('mbid'):
|
||||||
self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched')
|
self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched')
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"✅ Matched track '{item_name}' → MBID: {result['mbid']}")
|
logger.info(f"Matched track '{item_name}' → MBID: {result['mbid']}")
|
||||||
else:
|
else:
|
||||||
self.mb_service.update_track_mbid(item_id, None, 'not_found')
|
self.mb_service.update_track_mbid(item_id, None, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"❌ No match for track '{item_name}'")
|
logger.debug(f"No match for track '{item_name}'")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ class NavidromeTrack:
|
||||||
|
|
||||||
self._album_id = navidrome_data.get('albumId', '')
|
self._album_id = navidrome_data.get('albumId', '')
|
||||||
self._artist_id = navidrome_data.get('artistId', '')
|
self._artist_id = navidrome_data.get('artistId', '')
|
||||||
|
self.musicBrainzId = navidrome_data.get('musicBrainzId')
|
||||||
|
|
||||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||||
if not date_str:
|
if not date_str:
|
||||||
|
|
@ -172,7 +173,7 @@ class NavidromeClient:
|
||||||
self._artist_cache.clear()
|
self._artist_cache.clear()
|
||||||
self._album_cache.clear()
|
self._album_cache.clear()
|
||||||
self._track_cache.clear()
|
self._track_cache.clear()
|
||||||
logger.info("🔄 Navidrome client config reset — will reconnect with new settings")
|
logger.info("Navidrome client config reset — will reconnect with new settings")
|
||||||
|
|
||||||
def get_music_folders(self) -> list:
|
def get_music_folders(self) -> list:
|
||||||
"""Get available music folders from Navidrome."""
|
"""Get available music folders from Navidrome."""
|
||||||
|
|
@ -832,7 +833,7 @@ class NavidromeClient:
|
||||||
response = self._make_request('createPlaylist', params)
|
response = self._make_request('createPlaylist', params)
|
||||||
|
|
||||||
if response and response.get('status') == 'ok':
|
if response and response.get('status') == 'ok':
|
||||||
logger.info(f"✅ {'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
logger.info(f"{'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
|
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
|
||||||
|
|
@ -876,7 +877,7 @@ class NavidromeClient:
|
||||||
try:
|
try:
|
||||||
success = self.create_playlist(target_name, source_tracks)
|
success = self.create_playlist(target_name, source_tracks)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"✅ Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to create backup playlist '{target_name}'")
|
logger.error(f"Failed to create backup playlist '{target_name}'")
|
||||||
|
|
@ -937,13 +938,13 @@ class NavidromeClient:
|
||||||
# If we have existing playlists and want to backup, use the first one found
|
# If we have existing playlists and want to backup, use the first one found
|
||||||
if existing_playlists and create_backup:
|
if existing_playlists and create_backup:
|
||||||
backup_name = f"{playlist_name} Backup"
|
backup_name = f"{playlist_name} Backup"
|
||||||
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
logger.info(f"Creating backup playlist '{backup_name}' before sync")
|
||||||
|
|
||||||
# We only need to backup once, even if duplicates exist
|
# We only need to backup once, even if duplicates exist
|
||||||
if self.copy_playlist(playlist_name, backup_name):
|
if self.copy_playlist(playlist_name, backup_name):
|
||||||
logger.info(f"✅ Backup created successfully")
|
logger.info(f"Backup created successfully")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
logger.warning(f"Failed to create backup, continuing with sync")
|
||||||
|
|
||||||
# STRATEGY: Update the first match, delete the rest
|
# STRATEGY: Update the first match, delete the rest
|
||||||
if existing_playlists:
|
if existing_playlists:
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,7 @@ class PlexClient:
|
||||||
# Create new playlist with copied tracks
|
# Create new playlist with copied tracks
|
||||||
try:
|
try:
|
||||||
self.server.createPlaylist(target_name, items=valid_tracks)
|
self.server.createPlaylist(target_name, items=valid_tracks)
|
||||||
logger.info(f"✅ Created backup playlist '{target_name}' with {len(valid_tracks)} tracks")
|
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks")
|
||||||
return True
|
return True
|
||||||
except Exception as create_error:
|
except Exception as create_error:
|
||||||
logger.error(f"Failed to create backup playlist: {create_error}")
|
logger.error(f"Failed to create backup playlist: {create_error}")
|
||||||
|
|
@ -435,7 +435,7 @@ class PlexClient:
|
||||||
try:
|
try:
|
||||||
new_playlist = self.server.createPlaylist(target_name)
|
new_playlist = self.server.createPlaylist(target_name)
|
||||||
new_playlist.addItems(valid_tracks)
|
new_playlist.addItems(valid_tracks)
|
||||||
logger.info(f"✅ Created backup playlist '{target_name}' with {len(valid_tracks)} tracks (alternative method)")
|
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks (alternative method)")
|
||||||
return True
|
return True
|
||||||
except Exception as alt_error:
|
except Exception as alt_error:
|
||||||
logger.error(f"Alternative backup creation also failed: {alt_error}")
|
logger.error(f"Alternative backup creation also failed: {alt_error}")
|
||||||
|
|
@ -461,12 +461,12 @@ class PlexClient:
|
||||||
|
|
||||||
if create_backup:
|
if create_backup:
|
||||||
backup_name = f"{playlist_name} Backup"
|
backup_name = f"{playlist_name} Backup"
|
||||||
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
logger.info(f"Creating backup playlist '{backup_name}' before sync")
|
||||||
|
|
||||||
if self.copy_playlist(playlist_name, backup_name):
|
if self.copy_playlist(playlist_name, backup_name):
|
||||||
logger.info(f"✅ Backup created successfully")
|
logger.info(f"Backup created successfully")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
logger.warning(f"Failed to create backup, continuing with sync")
|
||||||
|
|
||||||
# Delete original and recreate
|
# Delete original and recreate
|
||||||
existing_playlist.delete()
|
existing_playlist.delete()
|
||||||
|
|
@ -931,7 +931,7 @@ class PlexClient:
|
||||||
try:
|
try:
|
||||||
library = self.server.library.section(library_name)
|
library = self.server.library.section(library_name)
|
||||||
library.update() # Non-blocking scan request
|
library.update() # Non-blocking scan request
|
||||||
logger.info(f"🎵 Triggered Plex library scan for '{library_name}'")
|
logger.info(f"Triggered Plex library scan for '{library_name}'")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to trigger library scan for '{library_name}': {e}")
|
logger.error(f"Failed to trigger library scan for '{library_name}': {e}")
|
||||||
|
|
@ -940,7 +940,7 @@ class PlexClient:
|
||||||
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||||
"""Check if Plex library is currently scanning"""
|
"""Check if Plex library is currently scanning"""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
logger.debug(f"🔍 DEBUG: Not connected to Plex, cannot check scan status")
|
logger.debug(f"DEBUG: Not connected to Plex, cannot check scan status")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -949,31 +949,31 @@ class PlexClient:
|
||||||
# Check if library has a scanning attribute or is refreshing
|
# Check if library has a scanning attribute or is refreshing
|
||||||
# The Plex API exposes this through the library's refreshing property
|
# The Plex API exposes this through the library's refreshing property
|
||||||
refreshing = hasattr(library, 'refreshing') and library.refreshing
|
refreshing = hasattr(library, 'refreshing') and library.refreshing
|
||||||
logger.debug(f"🔍 DEBUG: Library.refreshing = {refreshing}")
|
logger.debug(f"DEBUG: Library.refreshing = {refreshing}")
|
||||||
|
|
||||||
if refreshing:
|
if refreshing:
|
||||||
logger.debug(f"🔍 DEBUG: Library is refreshing")
|
logger.debug(f"DEBUG: Library is refreshing")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Alternative method: Check server activities for scanning
|
# Alternative method: Check server activities for scanning
|
||||||
try:
|
try:
|
||||||
activities = self.server.activities()
|
activities = self.server.activities()
|
||||||
logger.debug(f"🔍 DEBUG: Found {len(activities)} server activities")
|
logger.debug(f"DEBUG: Found {len(activities)} server activities")
|
||||||
|
|
||||||
for activity in activities:
|
for activity in activities:
|
||||||
# Look for library scan activities
|
# Look for library scan activities
|
||||||
activity_type = getattr(activity, 'type', 'unknown')
|
activity_type = getattr(activity, 'type', 'unknown')
|
||||||
activity_title = getattr(activity, 'title', 'unknown')
|
activity_title = getattr(activity, 'title', 'unknown')
|
||||||
logger.debug(f"🔍 DEBUG: Activity - type: {activity_type}, title: {activity_title}")
|
logger.debug(f"DEBUG: Activity - type: {activity_type}, title: {activity_title}")
|
||||||
|
|
||||||
if (activity_type in ['library.scan', 'library.refresh'] and
|
if (activity_type in ['library.scan', 'library.refresh'] and
|
||||||
library_name.lower() in activity_title.lower()):
|
library_name.lower() in activity_title.lower()):
|
||||||
logger.debug(f"🔍 DEBUG: Found matching scan activity: {activity_title}")
|
logger.debug(f"DEBUG: Found matching scan activity: {activity_title}")
|
||||||
return True
|
return True
|
||||||
except Exception as activities_error:
|
except Exception as activities_error:
|
||||||
logger.debug(f"Could not check server activities: {activities_error}")
|
logger.debug(f"Could not check server activities: {activities_error}")
|
||||||
|
|
||||||
logger.debug(f"🔍 DEBUG: No scan activity detected")
|
logger.debug(f"DEBUG: No scan activity detected")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,15 @@ class PlexScanManager:
|
||||||
if self._scan_in_progress:
|
if self._scan_in_progress:
|
||||||
# Plex is currently scanning - mark that we need another scan later
|
# Plex is currently scanning - mark that we need another scan later
|
||||||
self._downloads_during_scan = True
|
self._downloads_during_scan = True
|
||||||
logger.info(f"📡 Plex scan in progress - queueing follow-up scan ({reason})")
|
logger.info(f"Plex scan in progress - queueing follow-up scan ({reason})")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Cancel any existing timer and start a new one
|
# Cancel any existing timer and start a new one
|
||||||
if self._timer:
|
if self._timer:
|
||||||
self._timer.cancel()
|
self._timer.cancel()
|
||||||
logger.debug(f"⏳ Resetting scan timer ({reason})")
|
logger.debug(f"Resetting scan timer ({reason})")
|
||||||
else:
|
else:
|
||||||
logger.info(f"⏳ Plex scan queued - will execute in {self.delay}s ({reason})")
|
logger.info(f"Plex scan queued - will execute in {self.delay}s ({reason})")
|
||||||
|
|
||||||
# Start the debounce timer
|
# Start the debounce timer
|
||||||
self._timer = threading.Timer(self.delay, self._execute_scan)
|
self._timer = threading.Timer(self.delay, self._execute_scan)
|
||||||
|
|
@ -105,17 +105,17 @@ class PlexScanManager:
|
||||||
self._timer = None
|
self._timer = None
|
||||||
self._scan_start_time = time.time()
|
self._scan_start_time = time.time()
|
||||||
|
|
||||||
logger.info("🎵 Starting Plex library scan...")
|
logger.info("Starting Plex library scan...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = self.plex_client.trigger_library_scan()
|
success = self.plex_client.trigger_library_scan()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info("✅ Plex library scan initiated successfully")
|
logger.info("Plex library scan initiated successfully")
|
||||||
# Start new periodic update system instead of completion detection
|
# Start new periodic update system instead of completion detection
|
||||||
self._start_periodic_updates()
|
self._start_periodic_updates()
|
||||||
else:
|
else:
|
||||||
logger.error("❌ Failed to initiate Plex library scan")
|
logger.error("Failed to initiate Plex library scan")
|
||||||
self._reset_scan_state()
|
self._reset_scan_state()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -132,7 +132,7 @@ class PlexScanManager:
|
||||||
|
|
||||||
self._is_doing_periodic_updates = True
|
self._is_doing_periodic_updates = True
|
||||||
|
|
||||||
logger.info(f"🕒 Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
||||||
|
|
||||||
# Schedule first periodic update after 5 minutes
|
# Schedule first periodic update after 5 minutes
|
||||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||||
|
|
@ -160,20 +160,20 @@ class PlexScanManager:
|
||||||
is_scanning = self.plex_client.is_library_scanning("Music")
|
is_scanning = self.plex_client.is_library_scanning("Music")
|
||||||
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
||||||
|
|
||||||
logger.info(f"🕒 PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - Plex scanning: {is_scanning}")
|
logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - Plex scanning: {is_scanning}")
|
||||||
|
|
||||||
if is_scanning:
|
if is_scanning:
|
||||||
# Still scanning - trigger database update and continue periodic updates
|
# Still scanning - trigger database update and continue periodic updates
|
||||||
logger.info("🔄 Plex still scanning - triggering database update")
|
logger.info("Plex still scanning - triggering database update")
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
|
|
||||||
# Schedule next periodic update
|
# Schedule next periodic update
|
||||||
logger.info(f"🕒 Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
||||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||||
self._periodic_update_timer.start()
|
self._periodic_update_timer.start()
|
||||||
else:
|
else:
|
||||||
# Scanning stopped - final update and cleanup
|
# Scanning stopped - final update and cleanup
|
||||||
logger.info("✅ Plex scanning completed - doing final database update")
|
logger.info("Plex scanning completed - doing final database update")
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
self._stop_periodic_updates()
|
self._stop_periodic_updates()
|
||||||
|
|
||||||
|
|
@ -191,7 +191,7 @@ class PlexScanManager:
|
||||||
self._periodic_update_timer.cancel()
|
self._periodic_update_timer.cancel()
|
||||||
self._periodic_update_timer = None
|
self._periodic_update_timer = None
|
||||||
|
|
||||||
logger.info("🕒 Stopped periodic database updates")
|
logger.info("Stopped periodic database updates")
|
||||||
self._scan_completed()
|
self._scan_completed()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -222,7 +222,7 @@ class PlexScanManager:
|
||||||
else:
|
else:
|
||||||
# Scan completed!
|
# Scan completed!
|
||||||
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
||||||
logger.info(f"🎵 Plex library scan detected as completed (took {elapsed_time:.1f} seconds)")
|
logger.info(f"Plex library scan detected as completed (took {elapsed_time:.1f} seconds)")
|
||||||
self._scan_completed()
|
self._scan_completed()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -243,17 +243,17 @@ class PlexScanManager:
|
||||||
logger.debug("Scan completion callback called but scan was not in progress")
|
logger.debug("Scan completion callback called but scan was not in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("📡 Plex library scan completed")
|
logger.info("Plex library scan completed")
|
||||||
|
|
||||||
# Call registered completion callbacks
|
# Call registered completion callbacks
|
||||||
self._call_completion_callbacks()
|
self._call_completion_callbacks()
|
||||||
|
|
||||||
# Check if we need a follow-up scan
|
# Check if we need a follow-up scan
|
||||||
if downloads_during_scan:
|
if downloads_during_scan:
|
||||||
logger.info("🔄 Downloads occurred during scan - triggering follow-up scan")
|
logger.info("Downloads occurred during scan - triggering follow-up scan")
|
||||||
self.request_scan("Follow-up scan for downloads during previous scan")
|
self.request_scan("Follow-up scan for downloads during previous scan")
|
||||||
else:
|
else:
|
||||||
logger.info("✅ No downloads during scan - scan cycle complete")
|
logger.info("No downloads during scan - scan cycle complete")
|
||||||
|
|
||||||
def _call_completion_callbacks(self):
|
def _call_completion_callbacks(self):
|
||||||
"""Call all registered scan completion callbacks"""
|
"""Call all registered scan completion callbacks"""
|
||||||
|
|
@ -295,7 +295,7 @@ class PlexScanManager:
|
||||||
logger.warning("Force scan requested but scan already in progress")
|
logger.warning("Force scan requested but scan already in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("🚀 Force scan requested - executing immediately")
|
logger.info("Force scan requested - executing immediately")
|
||||||
self._execute_scan()
|
self._execute_scan()
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ _CONTENT_PATTERNS = [
|
||||||
(r'\bspoken\s*word\b', 'spoken_word'),
|
(r'\bspoken\s*word\b', 'spoken_word'),
|
||||||
(r'\bnarrat(?:ion|ed)\b', 'spoken_word'),
|
(r'\bnarrat(?:ion|ed)\b', 'spoken_word'),
|
||||||
(r'\bintroduction\b', 'spoken_word'),
|
(r'\bintroduction\b', 'spoken_word'),
|
||||||
|
# Acappella
|
||||||
|
(r'\ba\s*cappella\b', 'acappella'),
|
||||||
|
(r'\bacappella\b', 'acappella'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ SEASONAL_CONFIG = {
|
||||||
"keywords": ["christmas", "xmas", "holiday", "santa", "jingle", "winter wonderland", "sleigh", "noel", "carol"],
|
"keywords": ["christmas", "xmas", "holiday", "santa", "jingle", "winter wonderland", "sleigh", "noel", "carol"],
|
||||||
"active_months": [11, 12], # November-December
|
"active_months": [11, 12], # November-December
|
||||||
"playlist_size": 50,
|
"playlist_size": 50,
|
||||||
"icon": "🎄"
|
"icon": "🎅"
|
||||||
},
|
},
|
||||||
"valentines": {
|
"valentines": {
|
||||||
"name": "Love Songs",
|
"name": "Love Songs",
|
||||||
|
|
@ -36,7 +36,7 @@ SEASONAL_CONFIG = {
|
||||||
"keywords": ["love", "valentine", "romance", "heart", "romantic", "darling"],
|
"keywords": ["love", "valentine", "romance", "heart", "romantic", "darling"],
|
||||||
"active_months": [2], # February
|
"active_months": [2], # February
|
||||||
"playlist_size": 50,
|
"playlist_size": 50,
|
||||||
"icon": "❤️"
|
"icon": "💝"
|
||||||
},
|
},
|
||||||
"summer": {
|
"summer": {
|
||||||
"name": "Summer Vibes",
|
"name": "Summer Vibes",
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,7 @@ class SoulseekClient:
|
||||||
|
|
||||||
|
|
||||||
if response.status in [200, 201, 204]: # Accept 200 OK, 201 Created, and 204 No Content
|
if response.status in [200, 201, 204]: # Accept 200 OK, 201 Created, and 204 No Content
|
||||||
|
self._last_401_logged = False # Reset on success
|
||||||
try:
|
try:
|
||||||
if response_text.strip(): # Only parse if there's content
|
if response_text.strip(): # Only parse if there's content
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
@ -362,11 +363,17 @@ class SoulseekClient:
|
||||||
# Enhanced error logging for better debugging
|
# Enhanced error logging for better debugging
|
||||||
error_detail = response_text if response_text.strip() else "No error details provided"
|
error_detail = response_text if response_text.strip() else "No error details provided"
|
||||||
|
|
||||||
# Reduce noise for expected 404s during search cleanup
|
|
||||||
# Reduce noise for expected 404s (e.g. status checks for YouTube downloads)
|
# Reduce noise for expected 404s (e.g. status checks for YouTube downloads)
|
||||||
|
# and repeated 401s (slskd not running / bad credentials)
|
||||||
if response.status == 404:
|
if response.status == 404:
|
||||||
logger.debug(f"API request returned 404 (Not Found) for {url}")
|
logger.debug(f"API request returned 404 (Not Found) for {url}")
|
||||||
|
elif response.status == 401:
|
||||||
|
if not getattr(self, '_last_401_logged', False):
|
||||||
|
logger.warning(f"slskd authentication failed (401) — check API key. Suppressing further 401 errors.")
|
||||||
|
self._last_401_logged = True
|
||||||
|
logger.debug(f"API request 401 for {url}")
|
||||||
else:
|
else:
|
||||||
|
self._last_401_logged = False
|
||||||
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
|
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
|
||||||
logger.debug(f"Failed request: {method} {url}")
|
logger.debug(f"Failed request: {method} {url}")
|
||||||
|
|
||||||
|
|
@ -1029,10 +1036,10 @@ class SoulseekClient:
|
||||||
logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}")
|
logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}")
|
||||||
response = await self._make_request('DELETE', endpoint)
|
response = await self._make_request('DELETE', endpoint)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
logger.info(f"✅ Successfully cancelled download using endpoint format {i+1}")
|
logger.info(f"Successfully cancelled download using endpoint format {i+1}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.debug(f"❌ Endpoint format {i+1} failed: {endpoint}")
|
logger.debug(f"Endpoint format {i+1} failed: {endpoint}")
|
||||||
|
|
||||||
# Fallback: if download_id looks like a filename (contains path separators),
|
# Fallback: if download_id looks like a filename (contains path separators),
|
||||||
# list all transfers, find by filename, and cancel with the real transfer ID
|
# list all transfers, find by filename, and cancel with the real transfer ID
|
||||||
|
|
@ -1049,12 +1056,12 @@ class SoulseekClient:
|
||||||
logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}")
|
logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}")
|
||||||
response = await self._make_request('DELETE', fallback_endpoint)
|
response = await self._make_request('DELETE', fallback_endpoint)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
logger.info(f"✅ Successfully cancelled download via filename fallback")
|
logger.info(f"Successfully cancelled download via filename fallback")
|
||||||
return True
|
return True
|
||||||
except Exception as fallback_error:
|
except Exception as fallback_error:
|
||||||
logger.debug(f"Filename fallback failed: {fallback_error}")
|
logger.debug(f"Filename fallback failed: {fallback_error}")
|
||||||
|
|
||||||
logger.error(f"❌ All cancel endpoint formats failed for download_id: {download_id}")
|
logger.error(f"All cancel endpoint formats failed for download_id: {download_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1725,7 +1732,7 @@ class SoulseekClient:
|
||||||
async with session.get(swagger_url, headers=headers) as response:
|
async with session.get(swagger_url, headers=headers) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
swagger_data = await response.json()
|
swagger_data = await response.json()
|
||||||
logger.info("✓ Found Swagger documentation")
|
logger.info("Found Swagger documentation")
|
||||||
|
|
||||||
# Look for download/transfer related endpoints
|
# Look for download/transfer related endpoints
|
||||||
paths = swagger_data.get('paths', {})
|
paths = swagger_data.get('paths', {})
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ def _set_global_rate_limit(retry_after_seconds, endpoint_name, has_real_header=F
|
||||||
_rate_limit_endpoint = endpoint_name
|
_rate_limit_endpoint = endpoint_name
|
||||||
_rate_limit_set_at = now
|
_rate_limit_set_at = now
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban "
|
f"GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban "
|
||||||
f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) "
|
f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) "
|
||||||
f"triggered by {endpoint_name}"
|
f"triggered by {endpoint_name}"
|
||||||
)
|
)
|
||||||
|
|
@ -227,7 +227,7 @@ def _detect_and_set_rate_limit(exception, endpoint_name="unknown"):
|
||||||
logger.info(f"Rate limit detected on {endpoint_name} — Retry-After header: {delay}s")
|
logger.info(f"Rate limit detected on {endpoint_name} — Retry-After header: {delay}s")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
delay = _BASE_UNKNOWN_BAN
|
delay = _BASE_UNKNOWN_BAN
|
||||||
logger.warning(f"⚠️ Rate limit detected on {endpoint_name} — unparseable Retry-After: {retry_after}")
|
logger.warning(f"Rate limit detected on {endpoint_name} — unparseable Retry-After: {retry_after}")
|
||||||
else:
|
else:
|
||||||
# No Retry-After header available
|
# No Retry-After header available
|
||||||
if "max retries" in error_str.lower():
|
if "max retries" in error_str.lower():
|
||||||
|
|
@ -237,7 +237,7 @@ def _detect_and_set_rate_limit(exception, endpoint_name="unknown"):
|
||||||
delay = _BASE_MAX_RETRIES_BAN # 4 hours
|
delay = _BASE_MAX_RETRIES_BAN # 4 hours
|
||||||
else:
|
else:
|
||||||
delay = _BASE_UNKNOWN_BAN # 30 min
|
delay = _BASE_UNKNOWN_BAN # 30 min
|
||||||
logger.warning(f"⚠️ Rate limit detected on {endpoint_name} — no Retry-After header, using {delay}s default")
|
logger.warning(f"Rate limit detected on {endpoint_name} — no Retry-After header, using {delay}s default")
|
||||||
|
|
||||||
_set_global_rate_limit(delay, endpoint_name, has_real_header=has_real_header)
|
_set_global_rate_limit(delay, endpoint_name, has_real_header=has_real_header)
|
||||||
return True
|
return True
|
||||||
|
|
@ -312,7 +312,7 @@ def rate_limited(func):
|
||||||
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
|
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
|
||||||
|
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
logger.warning(f"⚠️ Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|
@ -323,7 +323,7 @@ def rate_limited(func):
|
||||||
|
|
||||||
elif is_server_error and attempt < max_retries:
|
elif is_server_error and attempt < max_retries:
|
||||||
delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32
|
delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32
|
||||||
logger.warning(f"⚠️ Spotify server error, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
logger.warning(f"Spotify server error, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -534,7 +534,7 @@ class SpotifyClient:
|
||||||
config = config_manager.get_spotify_config()
|
config = config_manager.get_spotify_config()
|
||||||
|
|
||||||
if not config.get('client_id') or not config.get('client_secret'):
|
if not config.get('client_id') or not config.get('client_secret'):
|
||||||
logger.warning("⚠️ Spotify credentials not configured")
|
logger.warning("Spotify credentials not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -630,7 +630,7 @@ class SpotifyClient:
|
||||||
# Minimum 30 min for auth probe 429s — these indicate persistent throttling
|
# Minimum 30 min for auth probe 429s — these indicate persistent throttling
|
||||||
ban_duration = max(delay, _BASE_UNKNOWN_BAN)
|
ban_duration = max(delay, _BASE_UNKNOWN_BAN)
|
||||||
_set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header)
|
_set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header)
|
||||||
logger.warning(f"⚠️ Auth probe rate limited — activating {ban_duration}s global ban")
|
logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban")
|
||||||
result = True
|
result = True
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Spotify authentication check failed: {e}")
|
logger.debug(f"Spotify authentication check failed: {e}")
|
||||||
|
|
@ -656,7 +656,7 @@ class SpotifyClient:
|
||||||
os.remove(cache_path)
|
os.remove(cache_path)
|
||||||
logger.info("Deleted Spotify cache file")
|
logger.info("Deleted Spotify cache file")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Failed to delete Spotify cache: {e}")
|
logger.warning(f"Failed to delete Spotify cache: {e}")
|
||||||
|
|
||||||
logger.info("Spotify client disconnected")
|
logger.info("Spotify client disconnected")
|
||||||
|
|
||||||
|
|
@ -1075,7 +1075,7 @@ class SpotifyClient:
|
||||||
return artists
|
return artists
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if '403' in str(e) or 'Forbidden' in str(e):
|
if '403' in str(e) or 'Forbidden' in str(e):
|
||||||
logger.warning("⚠️ Spotify user-follow-read scope not granted — re-authorize to see followed artists")
|
logger.warning("Spotify user-follow-read scope not granted — re-authorize to see followed artists")
|
||||||
return []
|
return []
|
||||||
_detect_and_set_rate_limit(e, 'get_followed_artists')
|
_detect_and_set_rate_limit(e, 'get_followed_artists')
|
||||||
logger.error(f"Error fetching followed artists: {e}")
|
logger.error(f"Error fetching followed artists: {e}")
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,10 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D
|
||||||
file_val = file_tags.get(file_key)
|
file_val = file_tags.get(file_key)
|
||||||
db_val = db_data.get(db_key)
|
db_val = db_data.get(db_key)
|
||||||
|
|
||||||
|
# Special: use per-track artist for Artist field when available (DJ mixes, compilations)
|
||||||
|
if file_key == 'artist' and db_data.get('track_artist'):
|
||||||
|
db_val = db_data['track_artist']
|
||||||
|
|
||||||
# Normalize for comparison
|
# Normalize for comparison
|
||||||
file_str = _normalize_for_compare(file_val)
|
file_str = _normalize_for_compare(file_val)
|
||||||
db_str = _normalize_for_compare(db_val)
|
db_str = _normalize_for_compare(db_val)
|
||||||
|
|
@ -230,9 +234,9 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
|
||||||
|
|
||||||
# Build metadata dict from DB data
|
# Build metadata dict from DB data
|
||||||
title = db_data.get('title')
|
title = db_data.get('title')
|
||||||
artist = db_data.get('artist_name')
|
artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes
|
||||||
album = db_data.get('album_title')
|
album = db_data.get('album_title')
|
||||||
album_artist = db_data.get('artist_name') # Use artist name as album artist
|
album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist
|
||||||
year = db_data.get('year')
|
year = db_data.get('year')
|
||||||
genres = db_data.get('genres')
|
genres = db_data.get('genres')
|
||||||
track_num = db_data.get('track_number')
|
track_num = db_data.get('track_number')
|
||||||
|
|
|
||||||
|
|
@ -472,9 +472,9 @@ class TidalClient:
|
||||||
result = self._exchange_code_for_tokens()
|
result = self._exchange_code_for_tokens()
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("✅ Token exchange successful")
|
logger.info("Token exchange successful")
|
||||||
else:
|
else:
|
||||||
logger.error("❌ Token exchange failed")
|
logger.error("Token exchange failed")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ def clean_track_name_for_search(track_name):
|
||||||
|
|
||||||
# Log cleaning if significant changes were made
|
# Log cleaning if significant changes were made
|
||||||
if cleaned_name != track_name:
|
if cleaned_name != track_name:
|
||||||
logger.debug(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
|
logger.debug(f"Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
|
||||||
|
|
||||||
return cleaned_name
|
return cleaned_name
|
||||||
|
|
||||||
|
|
@ -406,7 +406,7 @@ class WatchlistScanner:
|
||||||
def _disable_spotify_for_run(self, reason: str):
|
def _disable_spotify_for_run(self, reason: str):
|
||||||
"""Disable Spotify for rest of current run, once."""
|
"""Disable Spotify for rest of current run, once."""
|
||||||
if not self._spotify_disabled_for_run:
|
if not self._spotify_disabled_for_run:
|
||||||
logger.warning(f"⚠️ Spotify disabled for rest of run: {reason}")
|
logger.warning(f"Spotify disabled for rest of run: {reason}")
|
||||||
self._spotify_disabled_for_run = True
|
self._spotify_disabled_for_run = True
|
||||||
self._spotify_disabled_reason = reason
|
self._spotify_disabled_reason = reason
|
||||||
|
|
||||||
|
|
@ -640,7 +640,7 @@ class WatchlistScanner:
|
||||||
try:
|
try:
|
||||||
self._backfill_missing_ids(all_watchlist_artists, provider)
|
self._backfill_missing_ids(all_watchlist_artists, provider)
|
||||||
except Exception as backfill_error:
|
except Exception as backfill_error:
|
||||||
logger.warning(f"⚠️ Error during {provider} ID backfilling: {backfill_error}")
|
logger.warning(f"Error during {provider} ID backfilling: {backfill_error}")
|
||||||
# Continue with scan even if backfilling fails
|
# Continue with scan even if backfilling fails
|
||||||
|
|
||||||
scan_results = []
|
scan_results = []
|
||||||
|
|
@ -655,9 +655,9 @@ class WatchlistScanner:
|
||||||
self._disable_spotify_for_run("global Spotify rate limit active")
|
self._disable_spotify_for_run("global Spotify rate limit active")
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
logger.info(f"✅ Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found")
|
logger.info(f"Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"❌ Failed to scan {artist.artist_name}: {result.error_message}")
|
logger.warning(f"Failed to scan {artist.artist_name}: {result.error_message}")
|
||||||
|
|
||||||
# Rate limiting: Add delay between artists to avoid hitting Spotify API limits
|
# Rate limiting: Add delay between artists to avoid hitting Spotify API limits
|
||||||
# This is critical to prevent getting banned for 6+ hours
|
# This is critical to prevent getting banned for 6+ hours
|
||||||
|
|
@ -701,7 +701,7 @@ class WatchlistScanner:
|
||||||
self._disable_spotify_for_run("global Spotify rate limit active")
|
self._disable_spotify_for_run("global Spotify rate limit active")
|
||||||
self.sync_spotify_library_cache()
|
self.sync_spotify_library_cache()
|
||||||
except Exception as lib_err:
|
except Exception as lib_err:
|
||||||
logger.warning(f"⚠️ Error syncing Spotify library cache: {lib_err}")
|
logger.warning(f"Error syncing Spotify library cache: {lib_err}")
|
||||||
|
|
||||||
return scan_results
|
return scan_results
|
||||||
|
|
||||||
|
|
@ -1054,10 +1054,10 @@ class WatchlistScanner:
|
||||||
artists_to_match = [a for a in artists if not getattr(a, id_attr, None)]
|
artists_to_match = [a for a in artists if not getattr(a, id_attr, None)]
|
||||||
|
|
||||||
if not artists_to_match:
|
if not artists_to_match:
|
||||||
logger.info(f"✅ All artists already have {provider} IDs")
|
logger.info(f"All artists already have {provider} IDs")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
|
logger.info(f"Backfilling {len(artists_to_match)} artists with {provider} IDs...")
|
||||||
|
|
||||||
match_fn = {
|
match_fn = {
|
||||||
'spotify': self._match_to_spotify,
|
'spotify': self._match_to_spotify,
|
||||||
|
|
@ -1086,7 +1086,7 @@ class WatchlistScanner:
|
||||||
update_fn(artist.id, new_id)
|
update_fn(artist.id, new_id)
|
||||||
setattr(artist, id_attr, new_id)
|
setattr(artist, id_attr, new_id)
|
||||||
matched_count += 1
|
matched_count += 1
|
||||||
logger.info(f"✅ Matched '{artist.artist_name}' to {provider}: {new_id}")
|
logger.info(f"Matched '{artist.artist_name}' to {provider}: {new_id}")
|
||||||
else:
|
else:
|
||||||
unmatched_names.append(artist.artist_name)
|
unmatched_names.append(artist.artist_name)
|
||||||
|
|
||||||
|
|
@ -1097,9 +1097,9 @@ class WatchlistScanner:
|
||||||
unmatched_names.append(artist.artist_name)
|
unmatched_names.append(artist.artist_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"✅ Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
|
logger.info(f"Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
|
||||||
if unmatched_names:
|
if unmatched_names:
|
||||||
logger.warning(f"⚠️ Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}"
|
logger.warning(f"Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}"
|
||||||
f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually")
|
f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -1209,9 +1209,9 @@ class WatchlistScanner:
|
||||||
results = client.search_artists(artist_name, limit=5)
|
results = client.search_artists(artist_name, limit=5)
|
||||||
return self._best_artist_match(results, artist_name)
|
return self._best_artist_match(results, artist_name)
|
||||||
|
|
||||||
# Fallback: create a fresh Deezer client
|
# Fallback: use cached Deezer client
|
||||||
from core.deezer_client import DeezerClient
|
from core.metadata_service import get_deezer_client
|
||||||
client = DeezerClient()
|
client = get_deezer_client()
|
||||||
results = client.search_artists(artist_name, limit=5)
|
results = client.search_artists(artist_name, limit=5)
|
||||||
return self._best_artist_match(results, artist_name)
|
return self._best_artist_match(results, artist_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1221,8 +1221,8 @@ class WatchlistScanner:
|
||||||
def _match_to_discogs(self, artist_name: str) -> Optional[str]:
|
def _match_to_discogs(self, artist_name: str) -> Optional[str]:
|
||||||
"""Match artist name to Discogs ID using fuzzy name comparison."""
|
"""Match artist name to Discogs ID using fuzzy name comparison."""
|
||||||
try:
|
try:
|
||||||
from core.discogs_client import DiscogsClient
|
from core.metadata_service import get_discogs_client
|
||||||
client = DiscogsClient()
|
client = get_discogs_client()
|
||||||
results = client.search_artists(artist_name, limit=5)
|
results = client.search_artists(artist_name, limit=5)
|
||||||
return self._best_artist_match(results, artist_name)
|
return self._best_artist_match(results, artist_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1511,11 +1511,11 @@ class WatchlistScanner:
|
||||||
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name)
|
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
|
logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
|
||||||
return False # Track exists in library
|
return False # Track exists in library
|
||||||
|
|
||||||
# No match found with any variation or artist
|
# No match found with any variation or artist
|
||||||
logger.info(f"❌ Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist")
|
logger.info(f"Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist")
|
||||||
return True # Track is missing
|
return True # Track is missing
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -3212,7 +3212,7 @@ class WatchlistScanner:
|
||||||
logger.debug("Spotify not authenticated, skipping library cache sync")
|
logger.debug("Spotify not authenticated, skipping library cache sync")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("📚 Syncing Spotify library cache...")
|
logger.info("Syncing Spotify library cache...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
last_sync = self.database.get_metadata('spotify_library_last_sync')
|
last_sync = self.database.get_metadata('spotify_library_last_sync')
|
||||||
|
|
@ -3256,7 +3256,7 @@ class WatchlistScanner:
|
||||||
# Update last sync timestamp
|
# Update last sync timestamp
|
||||||
self.database.set_metadata('spotify_library_last_sync', datetime.now().isoformat())
|
self.database.set_metadata('spotify_library_last_sync', datetime.now().isoformat())
|
||||||
|
|
||||||
logger.info(f"✅ Spotify library cache sync complete — {len(albums)} albums processed")
|
logger.info(f"Spotify library cache sync complete — {len(albums)} albums processed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing Spotify library cache: {e}")
|
logger.error(f"Error syncing Spotify library cache: {e}")
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class WebScanManager:
|
||||||
if self._scan_in_progress:
|
if self._scan_in_progress:
|
||||||
# Server is currently scanning - mark that we need another scan later
|
# Server is currently scanning - mark that we need another scan later
|
||||||
self._downloads_during_scan = True
|
self._downloads_during_scan = True
|
||||||
logger.info(f"📡 Web scan in progress - queueing follow-up scan ({reason})")
|
logger.info(f"Web scan in progress - queueing follow-up scan ({reason})")
|
||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"message": "Scan already in progress, queued for later",
|
"message": "Scan already in progress, queued for later",
|
||||||
|
|
@ -101,9 +101,9 @@ class WebScanManager:
|
||||||
# Cancel any existing timer and start a new one
|
# Cancel any existing timer and start a new one
|
||||||
if self._timer:
|
if self._timer:
|
||||||
self._timer.cancel()
|
self._timer.cancel()
|
||||||
logger.debug(f"⏳ Resetting web scan timer ({reason})")
|
logger.debug(f"Resetting web scan timer ({reason})")
|
||||||
else:
|
else:
|
||||||
logger.info(f"⏳ Web scan queued - will execute in {self.delay}s ({reason})")
|
logger.info(f"Web scan queued - will execute in {self.delay}s ({reason})")
|
||||||
|
|
||||||
# Start the debounce timer
|
# Start the debounce timer
|
||||||
self._timer = threading.Timer(self.delay, self._execute_scan)
|
self._timer = threading.Timer(self.delay, self._execute_scan)
|
||||||
|
|
@ -182,12 +182,12 @@ class WebScanManager:
|
||||||
# Get the active media client
|
# Get the active media client
|
||||||
media_client, server_type = self._get_active_media_client()
|
media_client, server_type = self._get_active_media_client()
|
||||||
if not media_client:
|
if not media_client:
|
||||||
logger.error("❌ No active media client available for web library scan")
|
logger.error("No active media client available for web library scan")
|
||||||
self._reset_scan_state()
|
self._reset_scan_state()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._current_server_type = server_type
|
self._current_server_type = server_type
|
||||||
logger.info(f"🎵 Starting {server_type.upper()} library scan via web interface...")
|
logger.info(f"Starting {server_type.upper()} library scan via web interface...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update progress
|
# Update progress
|
||||||
|
|
@ -200,7 +200,7 @@ class WebScanManager:
|
||||||
success = media_client.trigger_library_scan()
|
success = media_client.trigger_library_scan()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"✅ {server_type.upper()} library scan initiated successfully via web")
|
logger.info(f"{server_type.upper()} library scan initiated successfully via web")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._scan_progress = {
|
self._scan_progress = {
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
|
@ -210,7 +210,7 @@ class WebScanManager:
|
||||||
# Start periodic completion checking
|
# Start periodic completion checking
|
||||||
self._start_periodic_completion_check()
|
self._start_periodic_completion_check()
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Failed to initiate {server_type.upper()} library scan via web")
|
logger.error(f"Failed to initiate {server_type.upper()} library scan via web")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._scan_progress = {
|
self._scan_progress = {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
|
|
@ -219,7 +219,7 @@ class WebScanManager:
|
||||||
self._reset_scan_state()
|
self._reset_scan_state()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error during {server_type.upper()} library scan via web: {e}")
|
logger.error(f"Error during {server_type.upper()} library scan via web: {e}")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._scan_progress = {
|
self._scan_progress = {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|
@ -265,7 +265,7 @@ class WebScanManager:
|
||||||
|
|
||||||
def _handle_scan_completion(self):
|
def _handle_scan_completion(self):
|
||||||
"""Handle scan completion and trigger callbacks"""
|
"""Handle scan completion and trigger callbacks"""
|
||||||
logger.info(f"🏁 Web {self._current_server_type.upper()} library scan completed")
|
logger.info(f"Web {self._current_server_type.upper()} library scan completed")
|
||||||
|
|
||||||
# Call completion callbacks
|
# Call completion callbacks
|
||||||
callbacks_to_call = []
|
callbacks_to_call = []
|
||||||
|
|
@ -274,7 +274,7 @@ class WebScanManager:
|
||||||
|
|
||||||
for callback in callbacks_to_call:
|
for callback in callbacks_to_call:
|
||||||
try:
|
try:
|
||||||
logger.info(f"🔄 Calling web scan completion callback: {callback.__name__}")
|
logger.info(f"Calling web scan completion callback: {callback.__name__}")
|
||||||
callback()
|
callback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in web scan completion callback {callback.__name__}: {e}")
|
logger.error(f"Error in web scan completion callback {callback.__name__}: {e}")
|
||||||
|
|
@ -285,7 +285,7 @@ class WebScanManager:
|
||||||
# Check if we need another scan due to downloads during this scan
|
# Check if we need another scan due to downloads during this scan
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._downloads_during_scan:
|
if self._downloads_during_scan:
|
||||||
logger.info("🔄 Web scan follow-up needed for downloads during scan")
|
logger.info("Web scan follow-up needed for downloads during scan")
|
||||||
self.request_scan("Follow-up scan for downloads during previous scan")
|
self.request_scan("Follow-up scan for downloads during previous scan")
|
||||||
|
|
||||||
def _reset_scan_state(self):
|
def _reset_scan_state(self):
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ class YouTubeClient:
|
||||||
self.download_path = Path(download_path)
|
self.download_path = Path(download_path)
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
logger.info(f"📁 YouTube client using download path: {self.download_path}")
|
logger.info(f"YouTube client using download path: {self.download_path}")
|
||||||
|
|
||||||
# Callback for shutdown check (avoids circular imports)
|
# Callback for shutdown check (avoids circular imports)
|
||||||
self.shutdown_check = None
|
self.shutdown_check = None
|
||||||
|
|
@ -123,11 +123,11 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Initialize production matching engine for parity with Soulseek
|
# Initialize production matching engine for parity with Soulseek
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
logger.info("✅ Initialized production MusicMatchingEngine")
|
logger.info("Initialized production MusicMatchingEngine")
|
||||||
|
|
||||||
# Check for ffmpeg (REQUIRED for MP3 conversion)
|
# Check for ffmpeg (REQUIRED for MP3 conversion)
|
||||||
if not self._check_ffmpeg():
|
if not self._check_ffmpeg():
|
||||||
logger.error("❌ ffmpeg is required but not found")
|
logger.error("ffmpeg is required but not found")
|
||||||
logger.error("The client will attempt to auto-download ffmpeg on first use")
|
logger.error("The client will attempt to auto-download ffmpeg on first use")
|
||||||
|
|
||||||
# Download queue management (mirrors Soulseek's download tracking)
|
# Download queue management (mirrors Soulseek's download tracking)
|
||||||
|
|
@ -201,7 +201,16 @@ class YouTubeClient:
|
||||||
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
||||||
elif 'cookiesfrombrowser' in self.download_opts:
|
elif 'cookiesfrombrowser' in self.download_opts:
|
||||||
del self.download_opts['cookiesfrombrowser']
|
del self.download_opts['cookiesfrombrowser']
|
||||||
logger.info(f"🔄 YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
|
|
||||||
|
# Reload download path
|
||||||
|
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
|
if new_path != self.download_path:
|
||||||
|
self.download_path = new_path
|
||||||
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.download_opts['outtmpl'] = str(self.download_path / '%(title)s.%(ext)s')
|
||||||
|
logger.info(f"YouTube download path updated to: {self.download_path}")
|
||||||
|
|
||||||
|
logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -356,7 +365,7 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Check if ffmpeg is in system PATH
|
# Check if ffmpeg is in system PATH
|
||||||
if shutil.which('ffmpeg'):
|
if shutil.which('ffmpeg'):
|
||||||
logger.info("✅ Found ffmpeg in system PATH")
|
logger.info("Found ffmpeg in system PATH")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Auto-download ffmpeg to tools folder if not found
|
# Auto-download ffmpeg to tools folder if not found
|
||||||
|
|
@ -373,7 +382,7 @@ class YouTubeClient:
|
||||||
|
|
||||||
# If we already have both locally, use them
|
# If we already have both locally, use them
|
||||||
if ffmpeg_path.exists() and ffprobe_path.exists():
|
if ffmpeg_path.exists() and ffprobe_path.exists():
|
||||||
logger.info(f"✅ Found ffmpeg and ffprobe in tools folder")
|
logger.info(f"Found ffmpeg and ffprobe in tools folder")
|
||||||
# Add to PATH so yt-dlp can find them
|
# Add to PATH so yt-dlp can find them
|
||||||
tools_dir_str = str(tools_dir.absolute())
|
tools_dir_str = str(tools_dir.absolute())
|
||||||
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
|
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
|
||||||
|
|
@ -451,10 +460,10 @@ class YouTubeClient:
|
||||||
ffprobe_zip.unlink() # Clean up zip
|
ffprobe_zip.unlink() # Clean up zip
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Unsupported platform: {system}")
|
logger.error(f"Unsupported platform: {system}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"✅ Downloaded ffmpeg to: {ffmpeg_path}")
|
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
|
||||||
|
|
||||||
# Add to PATH
|
# Add to PATH
|
||||||
tools_dir_str = str(tools_dir.absolute())
|
tools_dir_str = str(tools_dir.absolute())
|
||||||
|
|
@ -463,7 +472,7 @@ class YouTubeClient:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to download ffmpeg: {e}")
|
logger.error(f"Failed to download ffmpeg: {e}")
|
||||||
logger.error(f" Please install manually:")
|
logger.error(f" Please install manually:")
|
||||||
logger.error(f" Windows: scoop install ffmpeg")
|
logger.error(f" Windows: scoop install ffmpeg")
|
||||||
logger.error(f" Linux: sudo apt install ffmpeg")
|
logger.error(f" Linux: sudo apt install ffmpeg")
|
||||||
|
|
@ -568,7 +577,7 @@ class YouTubeClient:
|
||||||
this returns YouTubeSearchResult objects with video-specific metadata
|
this returns YouTubeSearchResult objects with video-specific metadata
|
||||||
(thumbnails, view counts, channel names) for UI display.
|
(thumbnails, view counts, channel names) for UI display.
|
||||||
"""
|
"""
|
||||||
logger.info(f"🎬 Searching YouTube videos for: {query}")
|
logger.info(f"Searching YouTube videos for: {query}")
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
|
@ -643,7 +652,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (track_results, album_results). Album results will always be empty for YouTube.
|
Tuple of (track_results, album_results). Album results will always be empty for YouTube.
|
||||||
"""
|
"""
|
||||||
logger.info(f"🔍 Searching YouTube for: {query}")
|
logger.info(f"Searching YouTube for: {query}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run yt-dlp in executor to avoid blocking event loop
|
# Run yt-dlp in executor to avoid blocking event loop
|
||||||
|
|
@ -693,13 +702,13 @@ class YouTubeClient:
|
||||||
track_result = self._youtube_to_track_result(entry, best_audio)
|
track_result = self._youtube_to_track_result(entry, best_audio)
|
||||||
track_results.append(track_result)
|
track_results.append(track_result)
|
||||||
|
|
||||||
logger.info(f"✅ Found {len(track_results)} YouTube tracks")
|
logger.info(f"Found {len(track_results)} YouTube tracks")
|
||||||
|
|
||||||
# Return tuple: (tracks, albums) - YouTube doesn't have albums, so return empty list
|
# Return tuple: (tracks, albums) - YouTube doesn't have albums, so return empty list
|
||||||
return (track_results, [])
|
return (track_results, [])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ YouTube search failed: {e}")
|
logger.error(f"YouTube search failed: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
@ -846,7 +855,7 @@ class YouTubeClient:
|
||||||
# Sort by confidence (best first)
|
# Sort by confidence (best first)
|
||||||
matches.sort(key=lambda r: r.confidence, reverse=True)
|
matches.sort(key=lambda r: r.confidence, reverse=True)
|
||||||
|
|
||||||
logger.info(f"✅ Found {len(matches)} matches above {min_confidence} confidence")
|
logger.info(f"Found {len(matches)} matches above {min_confidence} confidence")
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||||
|
|
@ -867,13 +876,13 @@ class YouTubeClient:
|
||||||
try:
|
try:
|
||||||
# Parse filename to extract video_id
|
# Parse filename to extract video_id
|
||||||
if '||' not in filename:
|
if '||' not in filename:
|
||||||
logger.error(f"❌ Invalid filename format: {filename}")
|
logger.error(f"Invalid filename format: {filename}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
video_id, title = filename.split('||', 1)
|
video_id, title = filename.split('||', 1)
|
||||||
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
|
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
logger.info(f"📥 Starting YouTube download: {title}")
|
logger.info(f"Starting YouTube download: {title}")
|
||||||
logger.info(f" URL: {youtube_url}")
|
logger.info(f" URL: {youtube_url}")
|
||||||
|
|
||||||
# Create unique download ID
|
# Create unique download ID
|
||||||
|
|
@ -905,11 +914,11 @@ class YouTubeClient:
|
||||||
)
|
)
|
||||||
download_thread.start()
|
download_thread.start()
|
||||||
|
|
||||||
logger.info(f"✅ YouTube download {download_id} started in background")
|
logger.info(f"YouTube download {download_id} started in background")
|
||||||
return download_id
|
return download_id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to start YouTube download: {e}")
|
logger.error(f"Failed to start YouTube download: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
@ -926,7 +935,7 @@ class YouTubeClient:
|
||||||
elapsed = time.time() - self._last_download_time
|
elapsed = time.time() - self._last_download_time
|
||||||
if self._last_download_time > 0 and elapsed < self._download_delay:
|
if self._last_download_time > 0 and elapsed < self._download_delay:
|
||||||
wait_time = self._download_delay - elapsed
|
wait_time = self._download_delay - elapsed
|
||||||
logger.info(f"⏳ Rate limiting: waiting {wait_time:.1f}s before next YouTube download")
|
logger.info(f"Rate limiting: waiting {wait_time:.1f}s before next YouTube download")
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
|
|
||||||
# Update state to downloading
|
# Update state to downloading
|
||||||
|
|
@ -958,17 +967,17 @@ class YouTubeClient:
|
||||||
self.active_downloads[download_id]['file_path'] = file_path
|
self.active_downloads[download_id]['file_path'] = file_path
|
||||||
# DO NOT update filename - keep original_filename for context matching
|
# DO NOT update filename - keep original_filename for context matching
|
||||||
|
|
||||||
logger.info(f"✅ YouTube download {download_id} completed: {file_path}")
|
logger.info(f"YouTube download {download_id} completed: {file_path}")
|
||||||
else:
|
else:
|
||||||
# Mark as errored
|
# Mark as errored
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id in self.active_downloads:
|
if download_id in self.active_downloads:
|
||||||
self.active_downloads[download_id]['state'] = 'Errored'
|
self.active_downloads[download_id]['state'] = 'Errored'
|
||||||
|
|
||||||
logger.error(f"❌ YouTube download {download_id} failed")
|
logger.error(f"YouTube download {download_id} failed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ YouTube download thread failed for {download_id}: {e}")
|
logger.error(f"YouTube download thread failed for {download_id}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
@ -997,7 +1006,7 @@ class YouTubeClient:
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
# Check for server shutdown using callback
|
# Check for server shutdown using callback
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
logger.info(f"🛑 Server shutting down, aborting download attempt {attempt + 1}")
|
logger.info(f"Server shutting down, aborting download attempt {attempt + 1}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1012,15 +1021,15 @@ class YouTubeClient:
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
# Drop browser cookies — authenticated sessions sometimes get restricted formats
|
# Drop browser cookies — authenticated sessions sometimes get restricted formats
|
||||||
if 'cookiesfrombrowser' in download_opts:
|
if 'cookiesfrombrowser' in download_opts:
|
||||||
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} without browser cookies")
|
logger.info(f"Retry {attempt + 1}/{max_retries} without browser cookies")
|
||||||
download_opts.pop('cookiesfrombrowser', None)
|
download_opts.pop('cookiesfrombrowser', None)
|
||||||
else:
|
else:
|
||||||
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with web_creator client")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
|
||||||
download_opts['extractor_args'] = {
|
download_opts['extractor_args'] = {
|
||||||
'youtube': { 'player_client': ['web_creator'] }
|
'youtube': { 'player_client': ['web_creator'] }
|
||||||
}
|
}
|
||||||
elif attempt >= 2:
|
elif attempt >= 2:
|
||||||
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
||||||
download_opts['format'] = 'best'
|
download_opts['format'] = 'best'
|
||||||
download_opts.pop('cookiesfrombrowser', None)
|
download_opts.pop('cookiesfrombrowser', None)
|
||||||
download_opts.pop('extractor_args', None)
|
download_opts.pop('extractor_args', None)
|
||||||
|
|
@ -1036,19 +1045,19 @@ class YouTubeClient:
|
||||||
if filename.exists():
|
if filename.exists():
|
||||||
return str(filename)
|
return str(filename)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Download completed but file not found: {filename}")
|
logger.error(f"Download completed but file not found: {filename}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
continue # Retry
|
continue # Retry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"❌ Download attempt {attempt + 1} failed: {error_msg}")
|
logger.error(f"Download attempt {attempt + 1} failed: {error_msg}")
|
||||||
|
|
||||||
# Check if it's a 403 error
|
# Check if it's a 403 error
|
||||||
if '403' in error_msg or 'Forbidden' in error_msg:
|
if '403' in error_msg or 'Forbidden' in error_msg:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
logger.info(f"⏳ Waiting 2 seconds before retry...")
|
logger.info(f"Waiting 2 seconds before retry...")
|
||||||
import time
|
import time
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
continue # Retry on 403
|
continue # Retry on 403
|
||||||
|
|
@ -1065,7 +1074,7 @@ class YouTubeClient:
|
||||||
return None # All retries failed
|
return None # All retries failed
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Download failed: {e}")
|
logger.error(f"Download failed: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
@ -1204,7 +1213,7 @@ class YouTubeClient:
|
||||||
# Remove them
|
# Remove them
|
||||||
for download_id in ids_to_remove:
|
for download_id in ids_to_remove:
|
||||||
del self.active_downloads[download_id]
|
del self.active_downloads[download_id]
|
||||||
logger.debug(f"🗑️ Cleared finished download {download_id}")
|
logger.debug(f"Cleared finished download {download_id}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1229,22 +1238,22 @@ class YouTubeClient:
|
||||||
try:
|
try:
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id not in self.active_downloads:
|
if download_id not in self.active_downloads:
|
||||||
logger.warning(f"⚠️ Download {download_id} not found")
|
logger.warning(f"Download {download_id} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Update state to cancelled
|
# Update state to cancelled
|
||||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||||
logger.info(f"⚠️ Marked YouTube download {download_id} as cancelled")
|
logger.info(f"Marked YouTube download {download_id} as cancelled")
|
||||||
|
|
||||||
# Remove from active downloads if requested
|
# Remove from active downloads if requested
|
||||||
if remove:
|
if remove:
|
||||||
del self.active_downloads[download_id]
|
del self.active_downloads[download_id]
|
||||||
logger.info(f"🗑️ Removed YouTube download {download_id} from queue")
|
logger.info(f"Removed YouTube download {download_id} from queue")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to cancel download {download_id}: {e}")
|
logger.error(f"Failed to cancel download {download_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _enhance_metadata(self, filepath: str, spotify_track: Optional[SpotifyTrack], yt_result: YouTubeSearchResult, track_number: int = 1, disc_number: int = 1, release_year: str = None, artist_genres: list = None):
|
def _enhance_metadata(self, filepath: str, spotify_track: Optional[SpotifyTrack], yt_result: YouTubeSearchResult, track_number: int = 1, disc_number: int = 1, release_year: str = None, artist_genres: list = None):
|
||||||
|
|
@ -1258,7 +1267,7 @@ class YouTubeClient:
|
||||||
from mutagen.id3 import ID3NoHeaderError
|
from mutagen.id3 import ID3NoHeaderError
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}")
|
logger.info(f"Enhancing metadata for: {Path(filepath).name}")
|
||||||
|
|
||||||
# Load MP3 file
|
# Load MP3 file
|
||||||
audio = MP3(filepath)
|
audio = MP3(filepath)
|
||||||
|
|
@ -1267,11 +1276,11 @@ class YouTubeClient:
|
||||||
if audio.tags is not None:
|
if audio.tags is not None:
|
||||||
# Delete ALL existing frames
|
# Delete ALL existing frames
|
||||||
audio.tags.clear()
|
audio.tags.clear()
|
||||||
logger.debug(f" 🧹 Cleared all existing tag frames")
|
logger.debug(f" Cleared all existing tag frames")
|
||||||
else:
|
else:
|
||||||
# No tags exist, add them
|
# No tags exist, add them
|
||||||
audio.add_tags()
|
audio.add_tags()
|
||||||
logger.debug(f" ➕ Added new tag structure")
|
logger.debug(f" Added new tag structure")
|
||||||
|
|
||||||
if spotify_track:
|
if spotify_track:
|
||||||
# Use Spotify metadata
|
# Use Spotify metadata
|
||||||
|
|
@ -1295,7 +1304,7 @@ class YouTubeClient:
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.debug(f" 📝 Setting metadata tags...")
|
logger.debug(f" Setting metadata tags...")
|
||||||
|
|
||||||
# Set ID3 tags (using setall to ensure they're set)
|
# Set ID3 tags (using setall to ensure they're set)
|
||||||
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
|
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
|
||||||
|
|
@ -1314,21 +1323,21 @@ class YouTubeClient:
|
||||||
# Combine up to 3 genres (matches production logic)
|
# Combine up to 3 genres (matches production logic)
|
||||||
genre = ', '.join(artist_genres[:3])
|
genre = ', '.join(artist_genres[:3])
|
||||||
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
|
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
|
||||||
logger.debug(f" ✓ Genre: {genre}")
|
logger.debug(f" Genre: {genre}")
|
||||||
|
|
||||||
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
|
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
|
||||||
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
|
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
|
||||||
|
|
||||||
logger.debug(f" ✓ Artist: {artist}")
|
logger.debug(f" Artist: {artist}")
|
||||||
logger.debug(f" ✓ Album Artist: {album_artist}")
|
logger.debug(f" Album Artist: {album_artist}")
|
||||||
logger.debug(f" ✓ Title: {title}")
|
logger.debug(f" Title: {title}")
|
||||||
logger.debug(f" ✓ Album: {album}")
|
logger.debug(f" Album: {album}")
|
||||||
logger.debug(f" ✓ Track #: {track_number}")
|
logger.debug(f" Track #: {track_number}")
|
||||||
logger.debug(f" ✓ Disc #: {disc_number}")
|
logger.debug(f" Disc #: {disc_number}")
|
||||||
logger.debug(f" ✓ Year: {year}")
|
logger.debug(f" Year: {year}")
|
||||||
|
|
||||||
# Fetch and embed album art from Spotify (via search)
|
# Fetch and embed album art from Spotify (via search)
|
||||||
logger.debug(f" 🎨 Fetching album art from Spotify...")
|
logger.debug(f" Fetching album art from Spotify...")
|
||||||
album_art_url = self._get_spotify_album_art(spotify_track)
|
album_art_url = self._get_spotify_album_art(spotify_track)
|
||||||
|
|
||||||
if album_art_url:
|
if album_art_url:
|
||||||
|
|
@ -1354,25 +1363,25 @@ class YouTubeClient:
|
||||||
data=response.content
|
data=response.content
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.debug(f" ✓ Album art embedded ({len(response.content) // 1024} KB)")
|
logger.debug(f" Album art embedded ({len(response.content) // 1024} KB)")
|
||||||
except Exception as art_error:
|
except Exception as art_error:
|
||||||
logger.warning(f" ⚠️ Could not embed album art: {art_error}")
|
logger.warning(f" Could not embed album art: {art_error}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f" ⚠️ No album art found on Spotify")
|
logger.warning(f" No album art found on Spotify")
|
||||||
|
|
||||||
# Save all tags
|
# Save all tags
|
||||||
audio.save()
|
audio.save()
|
||||||
logger.info(f"✅ Metadata enhanced successfully")
|
logger.info(f"Metadata enhanced successfully")
|
||||||
|
|
||||||
# Return album art URL for cover.jpg creation
|
# Return album art URL for cover.jpg creation
|
||||||
return album_art_url
|
return album_art_url
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("⚠️ mutagen not installed - skipping enhanced metadata tagging")
|
logger.warning("mutagen not installed - skipping enhanced metadata tagging")
|
||||||
logger.warning(" Install with: pip install mutagen")
|
logger.warning(" Install with: pip install mutagen")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Could not enhance metadata: {e}")
|
logger.warning(f"Could not enhance metadata: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_spotify_album_art(self, spotify_track: SpotifyTrack) -> Optional[str]:
|
def _get_spotify_album_art(self, spotify_track: SpotifyTrack) -> Optional[str]:
|
||||||
|
|
@ -1409,7 +1418,7 @@ class YouTubeClient:
|
||||||
logger.debug(f" ℹ️ cover.jpg already exists, skipping")
|
logger.debug(f" ℹ️ cover.jpg already exists, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f" 📥 Downloading cover.jpg...")
|
logger.debug(f" Downloading cover.jpg...")
|
||||||
|
|
||||||
response = requests.get(album_art_url, timeout=10)
|
response = requests.get(album_art_url, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -1417,10 +1426,10 @@ class YouTubeClient:
|
||||||
# Save to file
|
# Save to file
|
||||||
cover_path.write_bytes(response.content)
|
cover_path.write_bytes(response.content)
|
||||||
|
|
||||||
logger.debug(f" ✅ Saved cover.jpg ({len(response.content) // 1024} KB)")
|
logger.debug(f" Saved cover.jpg ({len(response.content) // 1024} KB)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ⚠️ Could not save cover.jpg: {e}")
|
logger.warning(f" Could not save cover.jpg: {e}")
|
||||||
|
|
||||||
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
|
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1431,10 +1440,10 @@ class YouTubeClient:
|
||||||
from core.lyrics_client import lyrics_client
|
from core.lyrics_client import lyrics_client
|
||||||
|
|
||||||
if not lyrics_client.api:
|
if not lyrics_client.api:
|
||||||
logger.debug(f" 🎵 LRClib API not available - skipping lyrics")
|
logger.debug(f" LRClib API not available - skipping lyrics")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f" 🎵 Fetching lyrics from LRClib...")
|
logger.debug(f" Fetching lyrics from LRClib...")
|
||||||
|
|
||||||
# Get track metadata
|
# Get track metadata
|
||||||
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
||||||
|
|
@ -1452,14 +1461,14 @@ class YouTubeClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.debug(f" ✅ Created .lrc lyrics file")
|
logger.debug(f" Created .lrc lyrics file")
|
||||||
else:
|
else:
|
||||||
logger.debug(f" 🎵 No lyrics found on LRClib")
|
logger.debug(f" No lyrics found on LRClib")
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics")
|
logger.debug(f" lyrics_client not available - skipping lyrics")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ⚠️ Could not create lyrics file: {e}")
|
logger.warning(f" Could not create lyrics file: {e}")
|
||||||
|
|
||||||
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
|
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1473,7 +1482,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
Path to downloaded file, or None if failed
|
Path to downloaded file, or None if failed
|
||||||
"""
|
"""
|
||||||
logger.info(f"🎯 Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
|
logger.info(f"Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
|
||||||
|
|
||||||
# Generate search query
|
# Generate search query
|
||||||
query = f"{spotify_track.artists[0]} {spotify_track.name}"
|
query = f"{spotify_track.artists[0]} {spotify_track.name}"
|
||||||
|
|
@ -1482,19 +1491,19 @@ class YouTubeClient:
|
||||||
results = self.search(query, max_results=10)
|
results = self.search(query, max_results=10)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
logger.error(f"❌ No YouTube results found for query: {query}")
|
logger.error(f"No YouTube results found for query: {query}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Find best matches
|
# Find best matches
|
||||||
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
|
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
logger.error(f"❌ No matches above {min_confidence} confidence threshold")
|
logger.error(f"No matches above {min_confidence} confidence threshold")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try downloading best match
|
# Try downloading best match
|
||||||
best_match = matches[0]
|
best_match = matches[0]
|
||||||
logger.info(f"🎯 Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
|
logger.info(f"Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
|
||||||
|
|
||||||
downloaded_file = self.download(best_match, spotify_track)
|
downloaded_file = self.download(best_match, spotify_track)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,9 @@ class MusicDatabase:
|
||||||
CREATE TABLE IF NOT EXISTS watchlist_artists (
|
CREATE TABLE IF NOT EXISTS watchlist_artists (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
spotify_artist_id TEXT UNIQUE,
|
spotify_artist_id TEXT UNIQUE,
|
||||||
|
itunes_artist_id TEXT,
|
||||||
|
deezer_artist_id TEXT,
|
||||||
|
discogs_artist_id TEXT,
|
||||||
artist_name TEXT NOT NULL,
|
artist_name TEXT NOT NULL,
|
||||||
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_scan_timestamp TIMESTAMP,
|
last_scan_timestamp TIMESTAMP,
|
||||||
|
|
@ -565,6 +568,16 @@ class MusicDatabase:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Migration: add track_artist column for per-track artist on compilations/DJ mixes
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT track_artist FROM tracks LIMIT 1")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE tracks ADD COLUMN track_artist TEXT")
|
||||||
|
logger.info("Added track_artist column to tracks table")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# One-time migration: purge discovery cache entries that lack track_number.
|
# One-time migration: purge discovery cache entries that lack track_number.
|
||||||
# Prior versions cached discovery results without track_number/disc_number/release_date,
|
# Prior versions cached discovery results without track_number/disc_number/release_date,
|
||||||
# causing incorrect file organization (all tracks as "01", missing album year).
|
# causing incorrect file organization (all tracks as "01", missing album year).
|
||||||
|
|
@ -1438,6 +1451,8 @@ class MusicDatabase:
|
||||||
include_acoustic INTEGER DEFAULT 0,
|
include_acoustic INTEGER DEFAULT 0,
|
||||||
include_compilations INTEGER DEFAULT 0,
|
include_compilations INTEGER DEFAULT 0,
|
||||||
itunes_artist_id TEXT,
|
itunes_artist_id TEXT,
|
||||||
|
deezer_artist_id TEXT,
|
||||||
|
discogs_artist_id TEXT,
|
||||||
profile_id INTEGER DEFAULT 1,
|
profile_id INTEGER DEFAULT 1,
|
||||||
UNIQUE(profile_id, spotify_artist_id),
|
UNIQUE(profile_id, spotify_artist_id),
|
||||||
UNIQUE(profile_id, itunes_artist_id)
|
UNIQUE(profile_id, itunes_artist_id)
|
||||||
|
|
@ -1461,7 +1476,9 @@ class MusicDatabase:
|
||||||
include_remixes INTEGER DEFAULT 0,
|
include_remixes INTEGER DEFAULT 0,
|
||||||
include_acoustic INTEGER DEFAULT 0,
|
include_acoustic INTEGER DEFAULT 0,
|
||||||
include_compilations INTEGER DEFAULT 0,
|
include_compilations INTEGER DEFAULT 0,
|
||||||
itunes_artist_id TEXT
|
itunes_artist_id TEXT,
|
||||||
|
deezer_artist_id TEXT,
|
||||||
|
discogs_artist_id TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -1472,7 +1489,7 @@ class MusicDatabase:
|
||||||
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
|
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
|
||||||
'include_albums', 'include_eps', 'include_singles', 'include_live',
|
'include_albums', 'include_eps', 'include_singles', 'include_live',
|
||||||
'include_remixes', 'include_acoustic', 'include_compilations',
|
'include_remixes', 'include_acoustic', 'include_compilations',
|
||||||
'itunes_artist_id', 'profile_id']
|
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
|
||||||
shared_cols = [c for c in new_cols if c in old_cols]
|
shared_cols = [c for c in new_cols if c in old_cols]
|
||||||
cols_str = ', '.join(shared_cols)
|
cols_str = ', '.join(shared_cols)
|
||||||
cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
|
cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
|
||||||
|
|
@ -1514,7 +1531,7 @@ class MusicDatabase:
|
||||||
cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT")
|
cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT")
|
||||||
columns_added = True
|
columns_added = True
|
||||||
if columns_added:
|
if columns_added:
|
||||||
logger.info("✅ Added MusicBrainz columns to artists table")
|
logger.info("Added MusicBrainz columns to artists table")
|
||||||
|
|
||||||
# --- Albums ---
|
# --- Albums ---
|
||||||
cursor.execute("PRAGMA table_info(albums)")
|
cursor.execute("PRAGMA table_info(albums)")
|
||||||
|
|
@ -1532,7 +1549,7 @@ class MusicDatabase:
|
||||||
added_albums = True
|
added_albums = True
|
||||||
if added_albums:
|
if added_albums:
|
||||||
columns_added = True
|
columns_added = True
|
||||||
logger.info("✅ Added MusicBrainz columns to albums table")
|
logger.info("Added MusicBrainz columns to albums table")
|
||||||
|
|
||||||
# --- Tracks ---
|
# --- Tracks ---
|
||||||
cursor.execute("PRAGMA table_info(tracks)")
|
cursor.execute("PRAGMA table_info(tracks)")
|
||||||
|
|
@ -1550,7 +1567,7 @@ class MusicDatabase:
|
||||||
added_tracks = True
|
added_tracks = True
|
||||||
if added_tracks:
|
if added_tracks:
|
||||||
columns_added = True
|
columns_added = True
|
||||||
logger.info("✅ Added MusicBrainz columns to tracks table")
|
logger.info("Added MusicBrainz columns to tracks table")
|
||||||
|
|
||||||
# Create MusicBrainz cache table for storing API results
|
# Create MusicBrainz cache table for storing API results
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|
@ -1583,7 +1600,7 @@ class MusicDatabase:
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_failed ON musicbrainz_cache (entity_type, last_updated) WHERE musicbrainz_id IS NULL")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_failed ON musicbrainz_cache (entity_type, last_updated) WHERE musicbrainz_id IS NULL")
|
||||||
|
|
||||||
if columns_added:
|
if columns_added:
|
||||||
logger.info("🎉 MusicBrainz migration completed successfully")
|
logger.info("MusicBrainz migration completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in MusicBrainz migration: {e}")
|
logger.error(f"Error in MusicBrainz migration: {e}")
|
||||||
|
|
@ -2201,6 +2218,8 @@ class MusicDatabase:
|
||||||
include_acoustic INTEGER DEFAULT 0,
|
include_acoustic INTEGER DEFAULT 0,
|
||||||
include_compilations INTEGER DEFAULT 0,
|
include_compilations INTEGER DEFAULT 0,
|
||||||
itunes_artist_id TEXT,
|
itunes_artist_id TEXT,
|
||||||
|
deezer_artist_id TEXT,
|
||||||
|
discogs_artist_id TEXT,
|
||||||
profile_id INTEGER DEFAULT 1,
|
profile_id INTEGER DEFAULT 1,
|
||||||
UNIQUE(profile_id, spotify_artist_id),
|
UNIQUE(profile_id, spotify_artist_id),
|
||||||
UNIQUE(profile_id, itunes_artist_id)
|
UNIQUE(profile_id, itunes_artist_id)
|
||||||
|
|
@ -2212,7 +2231,7 @@ class MusicDatabase:
|
||||||
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
|
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
|
||||||
'include_albums', 'include_eps', 'include_singles', 'include_live',
|
'include_albums', 'include_eps', 'include_singles', 'include_live',
|
||||||
'include_remixes', 'include_acoustic', 'include_compilations',
|
'include_remixes', 'include_acoustic', 'include_compilations',
|
||||||
'itunes_artist_id', 'profile_id']
|
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
|
||||||
shared_cols = [c for c in new_cols if c in col_names]
|
shared_cols = [c for c in new_cols if c in col_names]
|
||||||
cols_str = ', '.join(shared_cols)
|
cols_str = ', '.join(shared_cols)
|
||||||
|
|
||||||
|
|
@ -2892,7 +2911,7 @@ class MusicDatabase:
|
||||||
cleared = cursor.rowcount
|
cleared = cursor.rowcount
|
||||||
cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')")
|
cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')")
|
||||||
if cleared > 0:
|
if cleared > 0:
|
||||||
logger.info(f"🔄 SoulID v2 migration: cleared {cleared} artist soul_ids for regeneration")
|
logger.info(f"SoulID v2 migration: cleared {cleared} artist soul_ids for regeneration")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding soul_id columns: {e}")
|
logger.error(f"Error adding soul_id columns: {e}")
|
||||||
|
|
@ -3922,7 +3941,7 @@ class MusicDatabase:
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks WHERE artist_id IS NOT NULL)
|
WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks WHERE artist_id IS NOT NULL)
|
||||||
""")
|
""")
|
||||||
logger.info(f"🧹 Removed {orphaned_artists_count} orphaned artists")
|
logger.info(f"Removed {orphaned_artists_count} orphaned artists")
|
||||||
|
|
||||||
# Delete orphaned albums
|
# Delete orphaned albums
|
||||||
if orphaned_albums_count > 0:
|
if orphaned_albums_count > 0:
|
||||||
|
|
@ -3930,7 +3949,7 @@ class MusicDatabase:
|
||||||
DELETE FROM albums
|
DELETE FROM albums
|
||||||
WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL)
|
WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL)
|
||||||
""")
|
""")
|
||||||
logger.info(f"🧹 Removed {orphaned_albums_count} orphaned albums")
|
logger.info(f"Removed {orphaned_albums_count} orphaned albums")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
@ -3963,7 +3982,7 @@ class MusicDatabase:
|
||||||
duplicate_groups = cursor.fetchall()
|
duplicate_groups = cursor.fetchall()
|
||||||
|
|
||||||
if not duplicate_groups:
|
if not duplicate_groups:
|
||||||
logger.debug("🧹 No duplicate artists found")
|
logger.debug("No duplicate artists found")
|
||||||
return {'artists_merged': 0, 'albums_migrated': 0}
|
return {'artists_merged': 0, 'albums_migrated': 0}
|
||||||
|
|
||||||
total_merged = 0
|
total_merged = 0
|
||||||
|
|
@ -3983,7 +4002,7 @@ class MusicDatabase:
|
||||||
server_source = group['server_source']
|
server_source = group['server_source']
|
||||||
ids = group['ids'].split(',')
|
ids = group['ids'].split(',')
|
||||||
|
|
||||||
logger.info(f"🔄 Merging duplicate artist '{artist_name}' ({server_source}): IDs {ids}")
|
logger.info(f"Merging duplicate artist '{artist_name}' ({server_source}): IDs {ids}")
|
||||||
|
|
||||||
# Pick the keeper: the one with the most enrichment data
|
# Pick the keeper: the one with the most enrichment data
|
||||||
best_id = ids[0]
|
best_id = ids[0]
|
||||||
|
|
@ -4051,7 +4070,7 @@ class MusicDatabase:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if total_merged > 0:
|
if total_merged > 0:
|
||||||
logger.info(f"🧹 Duplicate merge complete: {total_merged} duplicates merged, {total_albums_migrated} albums migrated")
|
logger.info(f"Duplicate merge complete: {total_merged} duplicates merged, {total_albums_migrated} albums migrated")
|
||||||
|
|
||||||
return {'artists_merged': total_merged, 'albums_migrated': total_albums_migrated}
|
return {'artists_merged': total_merged, 'albums_migrated': total_albums_migrated}
|
||||||
|
|
||||||
|
|
@ -4282,7 +4301,7 @@ class MusicDatabase:
|
||||||
if existing_by_name:
|
if existing_by_name:
|
||||||
old_id = existing_by_name['id']
|
old_id = existing_by_name['id']
|
||||||
# ratingKey changed — migrate old artist to new ID, preserving enrichment data
|
# ratingKey changed — migrate old artist to new ID, preserving enrichment data
|
||||||
logger.info(f"🔄 Artist ratingKey migrated: '{name}' ({old_id} → {artist_id})")
|
logger.info(f"Artist ratingKey migrated: '{name}' ({old_id} → {artist_id})")
|
||||||
|
|
||||||
# Step 1: Insert new artist record, copying enrichment data from old
|
# Step 1: Insert new artist record, copying enrichment data from old
|
||||||
enrichment_cols = [
|
enrichment_cols = [
|
||||||
|
|
@ -4453,7 +4472,7 @@ class MusicDatabase:
|
||||||
if existing_by_title:
|
if existing_by_title:
|
||||||
old_id = existing_by_title['id']
|
old_id = existing_by_title['id']
|
||||||
# ratingKey changed — migrate old album to new ID, preserving enrichment data
|
# ratingKey changed — migrate old album to new ID, preserving enrichment data
|
||||||
logger.info(f"🔄 Album ratingKey migrated: '{title}' ({old_id} → {album_id})")
|
logger.info(f"Album ratingKey migrated: '{title}' ({old_id} → {album_id})")
|
||||||
|
|
||||||
enrichment_cols = [
|
enrichment_cols = [
|
||||||
'musicbrainz_release_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
|
'musicbrainz_release_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
|
||||||
|
|
@ -4598,7 +4617,39 @@ class MusicDatabase:
|
||||||
bitrate = track_obj.bitRate
|
bitrate = track_obj.bitRate
|
||||||
if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix:
|
if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix:
|
||||||
file_path = f"{track_obj.title}.{track_obj.suffix}"
|
file_path = f"{track_obj.title}.{track_obj.suffix}"
|
||||||
|
|
||||||
|
# Extract per-track artist for compilations/DJ mixes.
|
||||||
|
# Only stored when it differs from the album artist.
|
||||||
|
track_artist = None
|
||||||
|
# Plex: originalTitle holds the per-track artist on compilation albums
|
||||||
|
plex_original = getattr(track_obj, 'originalTitle', None)
|
||||||
|
if plex_original and plex_original.strip():
|
||||||
|
track_artist = plex_original.strip()
|
||||||
|
# Jellyfin/Emby: ArtistItems[0] is the track artist, may differ from album artist
|
||||||
|
if not track_artist and hasattr(track_obj, '_data'):
|
||||||
|
raw = getattr(track_obj, '_data', {}) or {}
|
||||||
|
artist_items = raw.get('ArtistItems', [])
|
||||||
|
if artist_items:
|
||||||
|
jf_track_artist = artist_items[0].get('Name', '')
|
||||||
|
album_artists = raw.get('AlbumArtists', [])
|
||||||
|
jf_album_artist = album_artists[0].get('Name', '') if album_artists else ''
|
||||||
|
if jf_track_artist and jf_track_artist != jf_album_artist:
|
||||||
|
track_artist = jf_track_artist
|
||||||
|
# Navidrome/Subsonic: artist attribute is per-track
|
||||||
|
if not track_artist and hasattr(track_obj, 'artist') and isinstance(getattr(track_obj, 'artist', None), str):
|
||||||
|
nav_artist = getattr(track_obj, 'artist', '').strip()
|
||||||
|
# Compare against album artist name to only store when different
|
||||||
|
try:
|
||||||
|
artist_row = cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,)).fetchone()
|
||||||
|
album_artist_name = artist_row[0] if artist_row else ''
|
||||||
|
if nav_artist and nav_artist.lower() != album_artist_name.lower():
|
||||||
|
track_artist = nav_artist
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract MusicBrainz recording ID from server if available (Navidrome provides this)
|
||||||
|
mbid = getattr(track_obj, 'musicBrainzId', None) or None
|
||||||
|
|
||||||
# Check if track already exists — UPDATE to preserve enrichment columns,
|
# Check if track already exists — UPDATE to preserve enrichment columns,
|
||||||
# INSERT only for genuinely new tracks
|
# INSERT only for genuinely new tracks
|
||||||
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
|
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
|
||||||
|
|
@ -4607,19 +4658,21 @@ class MusicDatabase:
|
||||||
if is_new_track:
|
if is_new_track:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO tracks
|
INSERT INTO tracks
|
||||||
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at)
|
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, musicbrainz_recording_id, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
|
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, mbid))
|
||||||
else:
|
else:
|
||||||
# Update server-provided fields only — preserves spotify_track_id, deezer_id,
|
# Update server-provided fields only — preserves spotify_track_id, deezer_id,
|
||||||
# isrc, bpm, musicbrainz IDs, and all other enrichment data
|
# isrc, bpm, and all other enrichment data
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE tracks
|
UPDATE tracks
|
||||||
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
|
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
|
||||||
duration = ?, file_path = ?, bitrate = ?, server_source = ?,
|
duration = ?, file_path = ?, bitrate = ?, server_source = ?,
|
||||||
|
track_artist = COALESCE(?, track_artist),
|
||||||
|
musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_id))
|
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, mbid, track_id))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
@ -4813,13 +4866,13 @@ class MusicDatabase:
|
||||||
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
|
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
|
||||||
|
|
||||||
if basic_results:
|
if basic_results:
|
||||||
logger.debug(f"🔍 Basic search found {len(basic_results)} results")
|
logger.debug(f"Basic search found {len(basic_results)} results")
|
||||||
return basic_results
|
return basic_results
|
||||||
|
|
||||||
# STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching
|
# STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching
|
||||||
fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source)
|
fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source)
|
||||||
if fuzzy_results:
|
if fuzzy_results:
|
||||||
logger.debug(f"🔍 Fuzzy fallback search found {len(fuzzy_results)} results")
|
logger.debug(f"Fuzzy fallback search found {len(fuzzy_results)} results")
|
||||||
|
|
||||||
return fuzzy_results
|
return fuzzy_results
|
||||||
|
|
||||||
|
|
@ -4837,9 +4890,11 @@ class MusicDatabase:
|
||||||
params.append(f"%{self._normalize_for_comparison(title)}%")
|
params.append(f"%{self._normalize_for_comparison(title)}%")
|
||||||
|
|
||||||
if artist:
|
if artist:
|
||||||
where_conditions.append("unidecode_lower(artists.name) LIKE ?")
|
norm_artist = f"%{self._normalize_for_comparison(artist)}%"
|
||||||
params.append(f"%{self._normalize_for_comparison(artist)}%")
|
where_conditions.append("(unidecode_lower(artists.name) LIKE ? OR unidecode_lower(COALESCE(tracks.track_artist, '')) LIKE ?)")
|
||||||
|
params.append(norm_artist)
|
||||||
|
params.append(norm_artist)
|
||||||
|
|
||||||
# Add server filter if specified
|
# Add server filter if specified
|
||||||
if server_source:
|
if server_source:
|
||||||
where_conditions.append("tracks.server_source = ?")
|
where_conditions.append("tracks.server_source = ?")
|
||||||
|
|
@ -4885,8 +4940,8 @@ class MusicDatabase:
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
for term in search_terms[:5]: # Limit to 5 terms to avoid too broad search
|
for term in search_terms[:5]: # Limit to 5 terms to avoid too broad search
|
||||||
like_conditions.append("(unidecode_lower(tracks.title) LIKE ? OR unidecode_lower(artists.name) LIKE ?)")
|
like_conditions.append("(unidecode_lower(tracks.title) LIKE ? OR unidecode_lower(artists.name) LIKE ? OR unidecode_lower(COALESCE(tracks.track_artist, '')) LIKE ?)")
|
||||||
params.extend([f"%{term}%", f"%{term}%"])
|
params.extend([f"%{term}%", f"%{term}%", f"%{term}%"])
|
||||||
|
|
||||||
if not like_conditions:
|
if not like_conditions:
|
||||||
return []
|
return []
|
||||||
|
|
@ -5062,7 +5117,7 @@ class MusicDatabase:
|
||||||
# Generate title variations for better matching (similar to album approach)
|
# Generate title variations for better matching (similar to album approach)
|
||||||
title_variations = self._generate_track_title_variations(title)
|
title_variations = self._generate_track_title_variations(title)
|
||||||
|
|
||||||
logger.debug(f"🔍 Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
logger.debug(f"Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||||
for i, var in enumerate(title_variations):
|
for i, var in enumerate(title_variations):
|
||||||
logger.debug(f" {i+1}. '{var}'")
|
logger.debug(f" {i+1}. '{var}'")
|
||||||
|
|
||||||
|
|
@ -5080,12 +5135,12 @@ class MusicDatabase:
|
||||||
if not potential_matches:
|
if not potential_matches:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"🎵 Found {len(potential_matches)} tracks for variation '{title_variation}'")
|
logger.debug(f"Found {len(potential_matches)} tracks for variation '{title_variation}'")
|
||||||
|
|
||||||
# Score each potential match
|
# Score each potential match
|
||||||
for track in potential_matches:
|
for track in potential_matches:
|
||||||
confidence = self._calculate_track_confidence(title, artist, track)
|
confidence = self._calculate_track_confidence(title, artist, track)
|
||||||
logger.debug(f" 🎯 '{track.title}' confidence: {confidence:.3f}")
|
logger.debug(f" '{track.title}' confidence: {confidence:.3f}")
|
||||||
|
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
|
|
@ -5093,13 +5148,13 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Return match only if it meets threshold
|
# Return match only if it meets threshold
|
||||||
if best_match and best_confidence >= confidence_threshold:
|
if best_match and best_confidence >= confidence_threshold:
|
||||||
logger.debug(f"✅ Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
logger.debug(f"Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||||
return best_match, best_confidence
|
return best_match, best_confidence
|
||||||
|
|
||||||
# Album-aware fallback: find album by title (any artist), check tracks on it
|
# Album-aware fallback: find album by title (any artist), check tracks on it
|
||||||
# Handles multi-artist albums filed under a different artist in the library
|
# Handles multi-artist albums filed under a different artist in the library
|
||||||
if album and best_confidence < confidence_threshold:
|
if album and best_confidence < confidence_threshold:
|
||||||
logger.debug(f"⚠️ Artist-specific search failed, trying album-aware fallback: '{title}' on '{album}'")
|
logger.debug(f"Artist-specific search failed, trying album-aware fallback: '{title}' on '{album}'")
|
||||||
try:
|
try:
|
||||||
album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
|
album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
|
||||||
for album_candidate in album_candidates:
|
for album_candidate in album_candidates:
|
||||||
|
|
@ -5139,12 +5194,12 @@ class MusicDatabase:
|
||||||
best_match = db_track
|
best_match = db_track
|
||||||
|
|
||||||
if best_match and best_confidence >= 0.7:
|
if best_match and best_confidence >= 0.7:
|
||||||
logger.debug(f"✅ Album-aware fallback matched: '{title}' on '{album}' -> '{best_match.title}' by '{best_match.artist_name}' (title_sim: {best_confidence:.3f})")
|
logger.debug(f"Album-aware fallback matched: '{title}' on '{album}' -> '{best_match.title}' by '{best_match.artist_name}' (title_sim: {best_confidence:.3f})")
|
||||||
return best_match, best_confidence
|
return best_match, best_confidence
|
||||||
except Exception as album_fallback_err:
|
except Exception as album_fallback_err:
|
||||||
logger.debug(f"Album-aware fallback error: {album_fallback_err}")
|
logger.debug(f"Album-aware fallback error: {album_fallback_err}")
|
||||||
|
|
||||||
logger.debug(f"❌ No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
|
logger.debug(f"No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
|
||||||
return None, best_confidence
|
return None, best_confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -5395,7 +5450,7 @@ class MusicDatabase:
|
||||||
# Generate album title variations for edition matching
|
# Generate album title variations for edition matching
|
||||||
title_variations = self._generate_album_title_variations(title)
|
title_variations = self._generate_album_title_variations(title)
|
||||||
|
|
||||||
logger.debug(f"🔍 Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
logger.debug(f"Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||||
for i, var in enumerate(title_variations):
|
for i, var in enumerate(title_variations):
|
||||||
logger.debug(f" {i+1}. '{var}'")
|
logger.debug(f" {i+1}. '{var}'")
|
||||||
|
|
||||||
|
|
@ -5416,7 +5471,7 @@ class MusicDatabase:
|
||||||
existing_ids.add(album.id)
|
existing_ids.add(album.id)
|
||||||
|
|
||||||
if albums:
|
if albums:
|
||||||
logger.debug(f"📀 Found {len(albums)} albums for variation '{variation}'")
|
logger.debug(f"Found {len(albums)} albums for variation '{variation}'")
|
||||||
|
|
||||||
if not albums:
|
if not albums:
|
||||||
continue
|
continue
|
||||||
|
|
@ -5424,7 +5479,7 @@ class MusicDatabase:
|
||||||
# Score each potential match with Smart Edition Matching
|
# Score each potential match with Smart Edition Matching
|
||||||
for album in albums:
|
for album in albums:
|
||||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
||||||
logger.debug(f" 🎯 '{album.title}' confidence: {confidence:.3f}")
|
logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
|
||||||
|
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
|
|
@ -5432,13 +5487,13 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Return match only if it meets threshold
|
# Return match only if it meets threshold
|
||||||
if best_match and best_confidence >= confidence_threshold:
|
if best_match and best_confidence >= confidence_threshold:
|
||||||
logger.debug(f"✅ Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||||
return best_match, best_confidence
|
return best_match, best_confidence
|
||||||
|
|
||||||
# Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
|
# Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
|
||||||
# If we haven't found a match yet, fetch broader list from artist and double check
|
# If we haven't found a match yet, fetch broader list from artist and double check
|
||||||
if best_confidence < confidence_threshold:
|
if best_confidence < confidence_threshold:
|
||||||
logger.debug(f"⚠️ specific title search failed, trying broad artist search fallback for '{artist}'")
|
logger.debug(f"specific title search failed, trying broad artist search fallback for '{artist}'")
|
||||||
try:
|
try:
|
||||||
# Get ALL albums by this artist (limit 100 to be safe)
|
# Get ALL albums by this artist (limit 100 to be safe)
|
||||||
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
|
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
|
||||||
|
|
@ -5462,18 +5517,18 @@ class MusicDatabase:
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
best_match = album
|
best_match = album
|
||||||
logger.debug(f" 🎯 Fallback match: '{album.title}' confidence: {confidence:.3f}")
|
logger.debug(f" Fallback match: '{album.title}' confidence: {confidence:.3f}")
|
||||||
except Exception as fallback_error:
|
except Exception as fallback_error:
|
||||||
logger.warning(f"Fallback artist search failed: {fallback_error}")
|
logger.warning(f"Fallback artist search failed: {fallback_error}")
|
||||||
|
|
||||||
if best_match and best_confidence >= confidence_threshold:
|
if best_match and best_confidence >= confidence_threshold:
|
||||||
logger.debug(f"✅ Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
logger.debug(f"Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||||
return best_match, best_confidence
|
return best_match, best_confidence
|
||||||
|
|
||||||
# Multi-artist fallback: search by title only (any artist)
|
# Multi-artist fallback: search by title only (any artist)
|
||||||
# Handles collaborative albums filed under a different artist in the library
|
# Handles collaborative albums filed under a different artist in the library
|
||||||
if best_confidence < confidence_threshold:
|
if best_confidence < confidence_threshold:
|
||||||
logger.debug(f"⚠️ Artist-specific search failed, trying title-only fallback for '{title}'")
|
logger.debug(f"Artist-specific search failed, trying title-only fallback for '{title}'")
|
||||||
try:
|
try:
|
||||||
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
|
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
|
||||||
for album in title_only_albums:
|
for album in title_only_albums:
|
||||||
|
|
@ -5482,15 +5537,15 @@ class MusicDatabase:
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
best_match = album
|
best_match = album
|
||||||
logger.debug(f" 🎯 Title-only match: '{album.title}' (confidence: {confidence:.3f})")
|
logger.debug(f" Title-only match: '{album.title}' (confidence: {confidence:.3f})")
|
||||||
except Exception as title_error:
|
except Exception as title_error:
|
||||||
logger.warning(f"Title-only fallback search failed: {title_error}")
|
logger.warning(f"Title-only fallback search failed: {title_error}")
|
||||||
|
|
||||||
if best_match and best_confidence >= confidence_threshold:
|
if best_match and best_confidence >= confidence_threshold:
|
||||||
logger.debug(f"✅ Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
logger.debug(f"Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||||
return best_match, best_confidence
|
return best_match, best_confidence
|
||||||
|
|
||||||
logger.debug(f"❌ No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
|
logger.debug(f"No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
|
||||||
return None, best_confidence
|
return None, best_confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -5616,7 +5671,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Log when normalized matching helps (only if it's the best score and better than others)
|
# Log when normalized matching helps (only if it's the best score and better than others)
|
||||||
if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity):
|
if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity):
|
||||||
logger.debug(f" 🌍 Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})")
|
logger.debug(f" Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})")
|
||||||
|
|
||||||
# Require minimum title similarity to prevent a perfect artist match from
|
# Require minimum title similarity to prevent a perfect artist match from
|
||||||
# carrying a bad title match over the threshold (e.g. "divisions" vs "silos")
|
# carrying a bad title match over the threshold (e.g. "divisions" vs "silos")
|
||||||
|
|
@ -5638,12 +5693,12 @@ class MusicDatabase:
|
||||||
# Found same/better edition (e.g., Deluxe when searching for Standard)
|
# Found same/better edition (e.g., Deluxe when searching for Standard)
|
||||||
edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1)
|
edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1)
|
||||||
confidence += edition_bonus
|
confidence += edition_bonus
|
||||||
logger.debug(f" 📀 Edition upgrade bonus: +{edition_bonus:.3f} ({db_album.track_count} >= {expected_track_count} tracks)")
|
logger.debug(f" Edition upgrade bonus: +{edition_bonus:.3f} ({db_album.track_count} >= {expected_track_count} tracks)")
|
||||||
elif db_album.track_count < expected_track_count * 0.8:
|
elif db_album.track_count < expected_track_count * 0.8:
|
||||||
# Found significantly smaller edition, apply penalty
|
# Found significantly smaller edition, apply penalty
|
||||||
edition_penalty = 0.1
|
edition_penalty = 0.1
|
||||||
confidence -= edition_penalty
|
confidence -= edition_penalty
|
||||||
logger.debug(f" 📀 Edition downgrade penalty: -{edition_penalty:.3f} ({db_album.track_count} << {expected_track_count} tracks)")
|
logger.debug(f" Edition downgrade penalty: -{edition_penalty:.3f} ({db_album.track_count} << {expected_track_count} tracks)")
|
||||||
|
|
||||||
return min(confidence, 1.0) # Cap at 1.0
|
return min(confidence, 1.0) # Cap at 1.0
|
||||||
|
|
||||||
|
|
@ -5754,7 +5809,7 @@ class MusicDatabase:
|
||||||
# Debug logging for Unicode normalization
|
# Debug logging for Unicode normalization
|
||||||
if search_title != search_title_norm or search_artist != search_artist_norm or \
|
if search_title != search_title_norm or search_artist != search_artist_norm or \
|
||||||
db_track.title != db_title_norm or db_track.artist_name != db_artist_norm:
|
db_track.title != db_title_norm or db_track.artist_name != db_artist_norm:
|
||||||
logger.debug(f"🔤 Unicode normalization:")
|
logger.debug(f"Unicode normalization:")
|
||||||
logger.debug(f" Search: '{search_title}' → '{search_title_norm}' | '{search_artist}' → '{search_artist_norm}'")
|
logger.debug(f" Search: '{search_title}' → '{search_title_norm}' | '{search_artist}' → '{search_artist_norm}'")
|
||||||
logger.debug(f" Database: '{db_track.title}' → '{db_title_norm}' | '{db_track.artist_name}' → '{db_artist_norm}'")
|
logger.debug(f" Database: '{db_track.title}' → '{db_title_norm}' | '{db_track.artist_name}' → '{db_artist_norm}'")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,26 @@ services:
|
||||||
cpus: '0.5'
|
cpus: '0.5'
|
||||||
memory: 512M
|
memory: 512M
|
||||||
|
|
||||||
|
## ─── Optional: slskd (Soulseek client) ───────────────────────────────
|
||||||
|
## Uncomment below to run slskd alongside SoulSync.
|
||||||
|
## Downloads land in ./downloads which SoulSync already reads from.
|
||||||
|
## After starting, set your SoulSync slskd URL to http://slskd:5030
|
||||||
|
## and the API key to whatever you set in SLSKD_API_KEY below.
|
||||||
|
#
|
||||||
|
# slskd:
|
||||||
|
# image: slskd/slskd:latest
|
||||||
|
# container_name: slskd
|
||||||
|
# environment:
|
||||||
|
# - SLSKD_REMOTE_CONFIGURATION=true
|
||||||
|
# - SLSKD_API_KEY=your-api-key-here
|
||||||
|
# - TZ=America/New_York
|
||||||
|
# ports:
|
||||||
|
# - "5030:5030"
|
||||||
|
# volumes:
|
||||||
|
# - ./slskd-data:/app
|
||||||
|
# - ./downloads:/app/downloads
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
# Named volumes for persistent data
|
# Named volumes for persistent data
|
||||||
volumes:
|
volumes:
|
||||||
soulsync_database:
|
soulsync_database:
|
||||||
|
|
|
||||||
7
requirements-dev.txt
Normal file
7
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# SoulSync development requirements
|
||||||
|
# Runtime web dependencies + test runner
|
||||||
|
|
||||||
|
-r requirements-webui.txt
|
||||||
|
|
||||||
|
# Test runner
|
||||||
|
pytest>=9.0.0
|
||||||
|
|
@ -271,9 +271,9 @@ class PlaylistSyncService:
|
||||||
for i, track in enumerate(media_tracks):
|
for i, track in enumerate(media_tracks):
|
||||||
if track and hasattr(track, 'ratingKey'):
|
if track and hasattr(track, 'ratingKey'):
|
||||||
valid_tracks.append(track)
|
valid_tracks.append(track)
|
||||||
logger.debug(f"✔️ Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
|
logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"❌ Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})")
|
logger.warning(f"Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})")
|
||||||
|
|
||||||
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys")
|
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys")
|
||||||
|
|
||||||
|
|
@ -312,7 +312,7 @@ class PlaylistSyncService:
|
||||||
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
|
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
|
||||||
wishlist_added_count = 0
|
wishlist_added_count = 0
|
||||||
if unmatched_tracks and getattr(self, '_skip_wishlist', False):
|
if unmatched_tracks and getattr(self, '_skip_wishlist', False):
|
||||||
logger.info(f"⚡ [Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks")
|
logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks")
|
||||||
unmatched_tracks = [] # Clear so the loop below doesn't run
|
unmatched_tracks = [] # Clear so the loop below doesn't run
|
||||||
if unmatched_tracks:
|
if unmatched_tracks:
|
||||||
try:
|
try:
|
||||||
|
|
@ -484,10 +484,10 @@ class PlaylistSyncService:
|
||||||
actual_track = None
|
actual_track = None
|
||||||
|
|
||||||
if actual_track:
|
if actual_track:
|
||||||
logger.debug(f"⚡ Sync cache hit: '{original_title}' → server track {server_track_id}")
|
logger.debug(f"Sync cache hit: '{original_title}' → server track {server_track_id}")
|
||||||
return actual_track, cached['confidence']
|
return actual_track, cached['confidence']
|
||||||
|
|
||||||
logger.debug(f"🔄 Sync cache stale for '{original_title}' — track {server_track_id} gone")
|
logger.debug(f"Sync cache stale for '{original_title}' — track {server_track_id} gone")
|
||||||
except Exception as cache_err:
|
except Exception as cache_err:
|
||||||
logger.debug(f"Sync cache lookup error: {cache_err}")
|
logger.debug(f"Sync cache lookup error: {cache_err}")
|
||||||
# --- End cache fast-path ---
|
# --- End cache fast-path ---
|
||||||
|
|
@ -511,7 +511,7 @@ class PlaylistSyncService:
|
||||||
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
||||||
|
|
||||||
# Save to sync match cache for next time
|
# Save to sync match cache for next time
|
||||||
if spotify_id:
|
if spotify_id:
|
||||||
|
|
@ -536,7 +536,7 @@ class PlaylistSyncService:
|
||||||
self.id = db_track.id
|
self.id = db_track.id
|
||||||
|
|
||||||
actual_track = JellyfinTrackFromDB(db_track)
|
actual_track = JellyfinTrackFromDB(db_track)
|
||||||
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
logger.debug(f"Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
||||||
return actual_track, confidence
|
return actual_track, confidence
|
||||||
elif server_type == "navidrome":
|
elif server_type == "navidrome":
|
||||||
# For Navidrome, create a track object from database info (similar to Jellyfin)
|
# For Navidrome, create a track object from database info (similar to Jellyfin)
|
||||||
|
|
@ -547,7 +547,7 @@ class PlaylistSyncService:
|
||||||
self.id = db_track.id
|
self.id = db_track.id
|
||||||
|
|
||||||
actual_track = NavidromeTrackFromDB(db_track)
|
actual_track = NavidromeTrackFromDB(db_track)
|
||||||
logger.debug(f"✔️ Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
logger.debug(f"Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
||||||
return actual_track, confidence
|
return actual_track, confidence
|
||||||
else:
|
else:
|
||||||
# For Plex, use the original fetchItem approach
|
# For Plex, use the original fetchItem approach
|
||||||
|
|
@ -556,16 +556,16 @@ class PlaylistSyncService:
|
||||||
track_id = int(db_track.id)
|
track_id = int(db_track.id)
|
||||||
actual_plex_track = media_client.server.fetchItem(track_id)
|
actual_plex_track = media_client.server.fetchItem(track_id)
|
||||||
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
|
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
|
||||||
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
|
logger.debug(f"Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
|
||||||
return actual_plex_track, confidence
|
return actual_plex_track, confidence
|
||||||
else:
|
else:
|
||||||
logger.warning(f"❌ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
|
logger.warning(f"Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"❌ Invalid Plex track ID format for '{db_track.title}' (ID: {db_track.id}) - skipping this track")
|
logger.warning(f"Invalid Plex track ID format for '{db_track.title}' (ID: {db_track.id}) - skipping this track")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except Exception as fetch_error:
|
except Exception as fetch_error:
|
||||||
logger.error(f"❌ Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
|
logger.error(f"Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
|
||||||
# Continue to try other artists rather than fail completely
|
# Continue to try other artists rather than fail completely
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -573,7 +573,7 @@ class PlaylistSyncService:
|
||||||
logger.error(f"Error checking track existence for '{original_title}' by '{artist_name}': {db_error}")
|
logger.error(f"Error checking track existence for '{original_title}' by '{artist_name}': {db_error}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"❌ No database match found for '{original_title}' by any of the artists {spotify_track.artists}")
|
logger.debug(f"No database match found for '{original_title}' by any of the artists {spotify_track.artists}")
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ _DEFAULT_DISCOVERY_STATES = {
|
||||||
'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10,
|
'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10,
|
||||||
'discovery_results': [
|
'discovery_results': [
|
||||||
{'index': 0, 'yt_track': 'Song B', 'yt_artist': 'Artist B',
|
{'index': 0, 'yt_track': 'Song B', 'yt_artist': 'Artist B',
|
||||||
'status': '✅ Found', 'status_class': 'found',
|
'status': 'Found', 'status_class': 'found',
|
||||||
'spotify_track': 'Song B', 'spotify_artist': 'Artist B',
|
'spotify_track': 'Song B', 'spotify_artist': 'Artist B',
|
||||||
'spotify_album': 'Album B'},
|
'spotify_album': 'Album B'},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
107
tests/test_metadata_service_cache.py
Normal file
107
tests/test_metadata_service_cache.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
if "spotipy" not in sys.modules:
|
||||||
|
spotipy = types.ModuleType("spotipy")
|
||||||
|
|
||||||
|
class _DummySpotify:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||||
|
|
||||||
|
class _DummyOAuth:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
spotipy.Spotify = _DummySpotify
|
||||||
|
oauth2.SpotifyOAuth = _DummyOAuth
|
||||||
|
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules["spotipy"] = spotipy
|
||||||
|
sys.modules["spotipy.oauth2"] = oauth2
|
||||||
|
|
||||||
|
if "config.settings" not in sys.modules:
|
||||||
|
config_pkg = types.ModuleType("config")
|
||||||
|
settings_mod = types.ModuleType("config.settings")
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_pkg.settings = settings_mod
|
||||||
|
sys.modules["config"] = config_pkg
|
||||||
|
sys.modules["config.settings"] = settings_mod
|
||||||
|
|
||||||
|
from core import metadata_service
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_metadata_client_cache():
|
||||||
|
metadata_service.clear_cached_metadata_clients()
|
||||||
|
yield
|
||||||
|
metadata_service.clear_cached_metadata_clients()
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_client_is_cached_for_same_source(monkeypatch):
|
||||||
|
calls = {"deezer": 0}
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer")
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
|
||||||
|
first = metadata_service.get_primary_client()
|
||||||
|
second = metadata_service.get_primary_client()
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
assert calls["deezer"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_client_switches_cache_by_source(monkeypatch):
|
||||||
|
calls = {"deezer": 0, "itunes": 0}
|
||||||
|
sources = iter(["deezer", "itunes"])
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
class FakeITunesClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["itunes"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: next(sources))
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
monkeypatch.setattr(metadata_service, "iTunesClient", FakeITunesClient)
|
||||||
|
|
||||||
|
deezer_client = metadata_service.get_primary_client()
|
||||||
|
itunes_client = metadata_service.get_primary_client()
|
||||||
|
|
||||||
|
assert deezer_client is not itunes_client
|
||||||
|
assert calls["deezer"] == 1
|
||||||
|
assert calls["itunes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deezer_client_cache_tracks_token(monkeypatch):
|
||||||
|
tokens = iter(["token-a", "token-b"])
|
||||||
|
calls = {"deezer": 0}
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
monkeypatch.setattr(config_manager, "get", lambda key, default=None: next(tokens) if key == "deezer.access_token" else default)
|
||||||
|
|
||||||
|
first = metadata_service.get_deezer_client()
|
||||||
|
second = metadata_service.get_deezer_client()
|
||||||
|
|
||||||
|
assert first is not second
|
||||||
|
assert calls["deezer"] == 2
|
||||||
|
|
@ -103,7 +103,7 @@ class TestActivityFeed:
|
||||||
client = socketio.test_client(app)
|
client = socketio.test_client(app)
|
||||||
# Add some activities first
|
# Add some activities first
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('🎵', 'Download Complete', 'Artist - Song', show_toast=False)
|
add_item('', 'Download Complete', 'Artist - Song', show_toast=False)
|
||||||
|
|
||||||
build = shared_state['build_activity_feed_payload']
|
build = shared_state['build_activity_feed_payload']
|
||||||
socketio.emit('dashboard:activity', build())
|
socketio.emit('dashboard:activity', build())
|
||||||
|
|
@ -123,7 +123,7 @@ class TestActivityFeed:
|
||||||
|
|
||||||
# Add an activity
|
# Add an activity
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('🎵', 'Test Activity', 'Test subtitle', show_toast=False)
|
add_item('', 'Test Activity', 'Test subtitle', show_toast=False)
|
||||||
|
|
||||||
http_data = flask_client.get('/api/activity/feed').get_json()
|
http_data = flask_client.get('/api/activity/feed').get_json()
|
||||||
build = shared_state['build_activity_feed_payload']
|
build = shared_state['build_activity_feed_payload']
|
||||||
|
|
@ -158,7 +158,7 @@ class TestToasts:
|
||||||
client.get_received() # clear
|
client.get_received() # clear
|
||||||
|
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('✅', 'Download Complete', 'Artist - Song', show_toast=True)
|
add_item('', 'Download Complete', 'Artist - Song', show_toast=True)
|
||||||
|
|
||||||
received = client.get_received()
|
received = client.get_received()
|
||||||
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
||||||
|
|
@ -174,7 +174,7 @@ class TestToasts:
|
||||||
client.get_received() # clear
|
client.get_received() # clear
|
||||||
|
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('📊', 'Background Task', 'Silent update', show_toast=False)
|
add_item('', 'Background Task', 'Silent update', show_toast=False)
|
||||||
|
|
||||||
received = client.get_received()
|
received = client.get_received()
|
||||||
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
||||||
|
|
@ -187,7 +187,7 @@ class TestToasts:
|
||||||
client.get_received() # clear
|
client.get_received() # clear
|
||||||
|
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('✅', 'Test Title', 'Test Subtitle', 'Now', show_toast=True)
|
add_item('', 'Test Title', 'Test Subtitle', 'Now', show_toast=True)
|
||||||
|
|
||||||
received = client.get_received()
|
received = client.get_received()
|
||||||
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
|
||||||
|
|
@ -205,7 +205,7 @@ class TestToasts:
|
||||||
"""GET /api/activity/toasts returns 200 with expected structure."""
|
"""GET /api/activity/toasts returns 200 with expected structure."""
|
||||||
# Add a toast-worthy activity first
|
# Add a toast-worthy activity first
|
||||||
add_item = shared_state['add_activity_item']
|
add_item = shared_state['add_activity_item']
|
||||||
add_item('✅', 'Test', 'Sub', show_toast=True)
|
add_item('', 'Test', 'Sub', show_toast=True)
|
||||||
|
|
||||||
resp = flask_client.get('/api/activity/toasts')
|
resp = flask_client.get('/api/activity/toasts')
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ if sys.platform == 'win32':
|
||||||
try:
|
try:
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("❌ yt-dlp not installed. Install with: pip install yt-dlp")
|
print("yt-dlp not installed. Install with: pip install yt-dlp")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Add parent directory to path to import from core
|
# Add parent directory to path to import from core
|
||||||
|
|
@ -122,12 +122,12 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Initialize production matching engine for parity with Soulseek
|
# Initialize production matching engine for parity with Soulseek
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
logger.info("✅ Initialized production MusicMatchingEngine")
|
logger.info("Initialized production MusicMatchingEngine")
|
||||||
|
|
||||||
# Check for ffmpeg (REQUIRED for MP3 conversion)
|
# Check for ffmpeg (REQUIRED for MP3 conversion)
|
||||||
if not self._check_ffmpeg():
|
if not self._check_ffmpeg():
|
||||||
print("\n" + "="*80)
|
print("\n" + "="*80)
|
||||||
print("❌ ERROR: ffmpeg is required but not found in PATH")
|
print("ERROR: ffmpeg is required but not found in PATH")
|
||||||
print("="*80)
|
print("="*80)
|
||||||
print("\nInstall ffmpeg:")
|
print("\nInstall ffmpeg:")
|
||||||
print(" Windows: scoop install ffmpeg")
|
print(" Windows: scoop install ffmpeg")
|
||||||
|
|
@ -195,7 +195,7 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Check if ffmpeg is in system PATH
|
# Check if ffmpeg is in system PATH
|
||||||
if shutil.which('ffmpeg'):
|
if shutil.which('ffmpeg'):
|
||||||
logger.info("✅ Found ffmpeg in system PATH")
|
logger.info("Found ffmpeg in system PATH")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Auto-download ffmpeg to tools folder if not found
|
# Auto-download ffmpeg to tools folder if not found
|
||||||
|
|
@ -211,7 +211,7 @@ class YouTubeClient:
|
||||||
|
|
||||||
# If we already have both locally, use them
|
# If we already have both locally, use them
|
||||||
if ffmpeg_path.exists() and ffprobe_path.exists():
|
if ffmpeg_path.exists() and ffprobe_path.exists():
|
||||||
logger.info(f"✅ Found ffmpeg and ffprobe in tools folder")
|
logger.info(f"Found ffmpeg and ffprobe in tools folder")
|
||||||
# Add to PATH so yt-dlp can find them
|
# Add to PATH so yt-dlp can find them
|
||||||
import os
|
import os
|
||||||
tools_dir_str = str(tools_dir.absolute())
|
tools_dir_str = str(tools_dir.absolute())
|
||||||
|
|
@ -290,10 +290,10 @@ class YouTubeClient:
|
||||||
ffprobe_zip.unlink() # Clean up zip
|
ffprobe_zip.unlink() # Clean up zip
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Unsupported platform: {system}")
|
logger.error(f"Unsupported platform: {system}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"✅ Downloaded ffmpeg to: {ffmpeg_path}")
|
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
|
||||||
|
|
||||||
# Add to PATH
|
# Add to PATH
|
||||||
import os
|
import os
|
||||||
|
|
@ -303,7 +303,7 @@ class YouTubeClient:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to download ffmpeg: {e}")
|
logger.error(f"Failed to download ffmpeg: {e}")
|
||||||
logger.error(f" Please install manually:")
|
logger.error(f" Please install manually:")
|
||||||
logger.error(f" Windows: scoop install ffmpeg")
|
logger.error(f" Windows: scoop install ffmpeg")
|
||||||
logger.error(f" Linux: sudo apt install ffmpeg")
|
logger.error(f" Linux: sudo apt install ffmpeg")
|
||||||
|
|
@ -331,7 +331,7 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Format speed safely
|
# Format speed safely
|
||||||
speed_kb = speed / 1024 if speed else 0
|
speed_kb = speed / 1024 if speed else 0
|
||||||
logger.info(f"📥 Progress: {progress:.1f}% | Speed: {speed_kb:.1f} KB/s | ETA: {eta}s")
|
logger.info(f"Progress: {progress:.1f}% | Speed: {speed_kb:.1f} KB/s | ETA: {eta}s")
|
||||||
|
|
||||||
elif d['status'] == 'finished':
|
elif d['status'] == 'finished':
|
||||||
self.current_download_progress = {
|
self.current_download_progress = {
|
||||||
|
|
@ -339,14 +339,14 @@ class YouTubeClient:
|
||||||
'progress': 100.0,
|
'progress': 100.0,
|
||||||
'filename': d.get('filename', '')
|
'filename': d.get('filename', '')
|
||||||
}
|
}
|
||||||
logger.info(f"✅ Download finished: {d.get('filename', '')}")
|
logger.info(f"Download finished: {d.get('filename', '')}")
|
||||||
|
|
||||||
elif d['status'] == 'error':
|
elif d['status'] == 'error':
|
||||||
self.current_download_progress = {
|
self.current_download_progress = {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error': str(d.get('error', 'Unknown error'))
|
'error': str(d.get('error', 'Unknown error'))
|
||||||
}
|
}
|
||||||
logger.error(f"❌ Download error: {d.get('error', '')}")
|
logger.error(f"Download error: {d.get('error', '')}")
|
||||||
|
|
||||||
def search(self, query: str, max_results: int = 10) -> List[YouTubeSearchResult]:
|
def search(self, query: str, max_results: int = 10) -> List[YouTubeSearchResult]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -359,7 +359,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
List of YouTubeSearchResult objects
|
List of YouTubeSearchResult objects
|
||||||
"""
|
"""
|
||||||
logger.info(f"🔍 Searching YouTube for: '{query}'")
|
logger.info(f"Searching YouTube for: '{query}'")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use YouTube Music for better music search results
|
# Use YouTube Music for better music search results
|
||||||
|
|
@ -406,11 +406,11 @@ class YouTubeClient:
|
||||||
logger.warning(f"Could not get detailed info for {entry['id']}: {e}")
|
logger.warning(f"Could not get detailed info for {entry['id']}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"✅ Found {len(results)} YouTube results")
|
logger.info(f"Found {len(results)} YouTube results")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ YouTube search error: {e}")
|
logger.error(f"YouTube search error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _get_best_audio_format(self, formats: List[Dict]) -> Optional[Dict]:
|
def _get_best_audio_format(self, formats: List[Dict]) -> Optional[Dict]:
|
||||||
|
|
@ -555,7 +555,7 @@ class YouTubeClient:
|
||||||
# Sort by confidence (best first)
|
# Sort by confidence (best first)
|
||||||
matches.sort(key=lambda r: r.confidence, reverse=True)
|
matches.sort(key=lambda r: r.confidence, reverse=True)
|
||||||
|
|
||||||
logger.info(f"✅ Found {len(matches)} matches above {min_confidence} confidence")
|
logger.info(f"Found {len(matches)} matches above {min_confidence} confidence")
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
def download(self, yt_result: YouTubeSearchResult, spotify_track: Optional[SpotifyTrack] = None) -> Optional[str]:
|
def download(self, yt_result: YouTubeSearchResult, spotify_track: Optional[SpotifyTrack] = None) -> Optional[str]:
|
||||||
|
|
@ -569,7 +569,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
Path to downloaded file, or None if failed
|
Path to downloaded file, or None if failed
|
||||||
"""
|
"""
|
||||||
logger.info(f"📥 Starting download: {yt_result.title}")
|
logger.info(f"Starting download: {yt_result.title}")
|
||||||
logger.info(f" Quality: {yt_result.available_quality}")
|
logger.info(f" Quality: {yt_result.available_quality}")
|
||||||
logger.info(f" URL: {yt_result.url}")
|
logger.info(f" URL: {yt_result.url}")
|
||||||
|
|
||||||
|
|
@ -617,9 +617,9 @@ class YouTubeClient:
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f" 📀 Spotify track #{track_number} on album: {spotify_track.album} ({release_year})")
|
logger.info(f" Spotify track #{track_number} on album: {spotify_track.album} ({release_year})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ⚠️ Could not fetch Spotify track details: {e}")
|
logger.warning(f" Could not fetch Spotify track details: {e}")
|
||||||
|
|
||||||
# If we have Spotify metadata, use production file organization
|
# If we have Spotify metadata, use production file organization
|
||||||
if spotify_track:
|
if spotify_track:
|
||||||
|
|
@ -644,8 +644,8 @@ class YouTubeClient:
|
||||||
# Override output template with production folder structure
|
# Override output template with production folder structure
|
||||||
download_opts['outtmpl'] = str(album_folder / f'{final_filename}.%(ext)s')
|
download_opts['outtmpl'] = str(album_folder / f'{final_filename}.%(ext)s')
|
||||||
|
|
||||||
logger.info(f" 📁 Album folder: {album_artist}/{album_artist} - {album}/")
|
logger.info(f" Album folder: {album_artist}/{album_artist} - {album}/")
|
||||||
logger.info(f" 📝 Filename: {final_filename}.mp3")
|
logger.info(f" Filename: {final_filename}.mp3")
|
||||||
|
|
||||||
# Add metadata postprocessor with Spotify info
|
# Add metadata postprocessor with Spotify info
|
||||||
download_opts['postprocessor_args'] = {
|
download_opts['postprocessor_args'] = {
|
||||||
|
|
@ -669,7 +669,7 @@ class YouTubeClient:
|
||||||
filename = Path(ydl.prepare_filename(info)).with_suffix('.mp3')
|
filename = Path(ydl.prepare_filename(info)).with_suffix('.mp3')
|
||||||
|
|
||||||
if filename.exists():
|
if filename.exists():
|
||||||
logger.info(f"✅ Download successful: {filename}")
|
logger.info(f"Download successful: {filename}")
|
||||||
|
|
||||||
# Post-download: Enhance metadata with mutagen
|
# Post-download: Enhance metadata with mutagen
|
||||||
album_art_url = self._enhance_metadata(str(filename), spotify_track, yt_result, track_number, disc_number, release_year, artist_genres)
|
album_art_url = self._enhance_metadata(str(filename), spotify_track, yt_result, track_number, disc_number, release_year, artist_genres)
|
||||||
|
|
@ -684,11 +684,11 @@ class YouTubeClient:
|
||||||
|
|
||||||
return str(filename)
|
return str(filename)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Download completed but file not found: {filename}")
|
logger.error(f"Download completed but file not found: {filename}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Download failed: {e}")
|
logger.error(f"Download failed: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
@ -704,7 +704,7 @@ class YouTubeClient:
|
||||||
from mutagen.id3 import ID3NoHeaderError
|
from mutagen.id3 import ID3NoHeaderError
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}")
|
logger.info(f"Enhancing metadata for: {Path(filepath).name}")
|
||||||
|
|
||||||
# Load MP3 file
|
# Load MP3 file
|
||||||
audio = MP3(filepath)
|
audio = MP3(filepath)
|
||||||
|
|
@ -713,11 +713,11 @@ class YouTubeClient:
|
||||||
if audio.tags is not None:
|
if audio.tags is not None:
|
||||||
# Delete ALL existing frames
|
# Delete ALL existing frames
|
||||||
audio.tags.clear()
|
audio.tags.clear()
|
||||||
logger.info(f" 🧹 Cleared all existing tag frames")
|
logger.info(f" Cleared all existing tag frames")
|
||||||
else:
|
else:
|
||||||
# No tags exist, add them
|
# No tags exist, add them
|
||||||
audio.add_tags()
|
audio.add_tags()
|
||||||
logger.info(f" ➕ Added new tag structure")
|
logger.info(f" Added new tag structure")
|
||||||
|
|
||||||
if spotify_track:
|
if spotify_track:
|
||||||
# Use Spotify metadata
|
# Use Spotify metadata
|
||||||
|
|
@ -741,7 +741,7 @@ class YouTubeClient:
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f" 📝 Setting metadata tags...")
|
logger.info(f" Setting metadata tags...")
|
||||||
|
|
||||||
# Set ID3 tags (using setall to ensure they're set)
|
# Set ID3 tags (using setall to ensure they're set)
|
||||||
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
|
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
|
||||||
|
|
@ -760,21 +760,21 @@ class YouTubeClient:
|
||||||
# Combine up to 3 genres (matches production logic)
|
# Combine up to 3 genres (matches production logic)
|
||||||
genre = ', '.join(artist_genres[:3])
|
genre = ', '.join(artist_genres[:3])
|
||||||
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
|
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
|
||||||
logger.info(f" ✓ Genre: {genre}")
|
logger.info(f" Genre: {genre}")
|
||||||
|
|
||||||
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
|
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
|
||||||
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
|
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
|
||||||
|
|
||||||
logger.info(f" ✓ Artist: {artist}")
|
logger.info(f" Artist: {artist}")
|
||||||
logger.info(f" ✓ Album Artist: {album_artist}")
|
logger.info(f" Album Artist: {album_artist}")
|
||||||
logger.info(f" ✓ Title: {title}")
|
logger.info(f" Title: {title}")
|
||||||
logger.info(f" ✓ Album: {album}")
|
logger.info(f" Album: {album}")
|
||||||
logger.info(f" ✓ Track #: {track_number}")
|
logger.info(f" Track #: {track_number}")
|
||||||
logger.info(f" ✓ Disc #: {disc_number}")
|
logger.info(f" Disc #: {disc_number}")
|
||||||
logger.info(f" ✓ Year: {year}")
|
logger.info(f" Year: {year}")
|
||||||
|
|
||||||
# Fetch and embed album art from Spotify (via search)
|
# Fetch and embed album art from Spotify (via search)
|
||||||
logger.info(f" 🎨 Fetching album art from Spotify...")
|
logger.info(f" Fetching album art from Spotify...")
|
||||||
album_art_url = self._get_spotify_album_art(spotify_track)
|
album_art_url = self._get_spotify_album_art(spotify_track)
|
||||||
|
|
||||||
if album_art_url:
|
if album_art_url:
|
||||||
|
|
@ -800,25 +800,25 @@ class YouTubeClient:
|
||||||
data=response.content
|
data=response.content
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.info(f" ✓ Album art embedded ({len(response.content) // 1024} KB)")
|
logger.info(f" Album art embedded ({len(response.content) // 1024} KB)")
|
||||||
except Exception as art_error:
|
except Exception as art_error:
|
||||||
logger.warning(f" ⚠️ Could not embed album art: {art_error}")
|
logger.warning(f" Could not embed album art: {art_error}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f" ⚠️ No album art found on Spotify")
|
logger.warning(f" No album art found on Spotify")
|
||||||
|
|
||||||
# Save all tags
|
# Save all tags
|
||||||
audio.save()
|
audio.save()
|
||||||
logger.info(f"✅ Metadata enhanced successfully")
|
logger.info(f"Metadata enhanced successfully")
|
||||||
|
|
||||||
# Return album art URL for cover.jpg creation
|
# Return album art URL for cover.jpg creation
|
||||||
return album_art_url
|
return album_art_url
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("⚠️ mutagen not installed - skipping enhanced metadata tagging")
|
logger.warning("mutagen not installed - skipping enhanced metadata tagging")
|
||||||
logger.warning(" Install with: pip install mutagen")
|
logger.warning(" Install with: pip install mutagen")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Could not enhance metadata: {e}")
|
logger.warning(f"Could not enhance metadata: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
@ -833,83 +833,83 @@ class YouTubeClient:
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient
|
||||||
|
|
||||||
logger.info(f" 🔍 Getting Spotify client...")
|
logger.info(f" Getting Spotify client...")
|
||||||
|
|
||||||
# Get authenticated Spotify client
|
# Get authenticated Spotify client
|
||||||
spotify_client = SpotifyClient()
|
spotify_client = SpotifyClient()
|
||||||
|
|
||||||
if not spotify_client:
|
if not spotify_client:
|
||||||
logger.warning(f" ⚠️ Spotify client not available")
|
logger.warning(f" Spotify client not available")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not spotify_client.is_authenticated():
|
if not spotify_client.is_authenticated():
|
||||||
logger.warning(f" ⚠️ Spotify client not authenticated")
|
logger.warning(f" Spotify client not authenticated")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f" ✓ Spotify client authenticated")
|
logger.info(f" Spotify client authenticated")
|
||||||
|
|
||||||
# Use the track ID if available (real Spotify IDs)
|
# Use the track ID if available (real Spotify IDs)
|
||||||
if spotify_track.id and not spotify_track.id.startswith('test'):
|
if spotify_track.id and not spotify_track.id.startswith('test'):
|
||||||
logger.info(f" 🔍 Fetching track info for ID: {spotify_track.id}")
|
logger.info(f" Fetching track info for ID: {spotify_track.id}")
|
||||||
try:
|
try:
|
||||||
# Get track info from Spotify API
|
# Get track info from Spotify API
|
||||||
track_info = spotify_client.sp.track(spotify_track.id)
|
track_info = spotify_client.sp.track(spotify_track.id)
|
||||||
|
|
||||||
if track_info:
|
if track_info:
|
||||||
logger.info(f" ✓ Got track info from Spotify")
|
logger.info(f" Got track info from Spotify")
|
||||||
|
|
||||||
if 'album' in track_info:
|
if 'album' in track_info:
|
||||||
album_images = track_info['album'].get('images', [])
|
album_images = track_info['album'].get('images', [])
|
||||||
logger.info(f" 📸 Found {len(album_images)} album images")
|
logger.info(f" Found {len(album_images)} album images")
|
||||||
|
|
||||||
if album_images:
|
if album_images:
|
||||||
# Get highest quality image (first in list)
|
# Get highest quality image (first in list)
|
||||||
album_art_url = album_images[0]['url']
|
album_art_url = album_images[0]['url']
|
||||||
logger.info(f" ✓ Album art URL: {album_art_url[:50]}...")
|
logger.info(f" Album art URL: {album_art_url[:50]}...")
|
||||||
return album_art_url
|
return album_art_url
|
||||||
else:
|
else:
|
||||||
logger.warning(f" ⚠️ No album data in track info")
|
logger.warning(f" No album data in track info")
|
||||||
else:
|
else:
|
||||||
logger.warning(f" ⚠️ Track info is empty")
|
logger.warning(f" Track info is empty")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ❌ Error fetching via track ID: {e}")
|
logger.warning(f" Error fetching via track ID: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# Fallback: Search for the track
|
# Fallback: Search for the track
|
||||||
query = f"track:{spotify_track.name} artist:{spotify_track.artists[0]}"
|
query = f"track:{spotify_track.name} artist:{spotify_track.artists[0]}"
|
||||||
logger.info(f" 🔍 Searching Spotify: {query}")
|
logger.info(f" Searching Spotify: {query}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
search_results = spotify_client.sp.search(q=query, type='track', limit=1)
|
search_results = spotify_client.sp.search(q=query, type='track', limit=1)
|
||||||
|
|
||||||
if search_results and 'tracks' in search_results:
|
if search_results and 'tracks' in search_results:
|
||||||
tracks = search_results['tracks'].get('items', [])
|
tracks = search_results['tracks'].get('items', [])
|
||||||
logger.info(f" 📋 Search returned {len(tracks)} tracks")
|
logger.info(f" Search returned {len(tracks)} tracks")
|
||||||
|
|
||||||
if tracks:
|
if tracks:
|
||||||
album_images = tracks[0].get('album', {}).get('images', [])
|
album_images = tracks[0].get('album', {}).get('images', [])
|
||||||
if album_images:
|
if album_images:
|
||||||
# Get highest quality image (first in list)
|
# Get highest quality image (first in list)
|
||||||
album_art_url = album_images[0]['url']
|
album_art_url = album_images[0]['url']
|
||||||
logger.info(f" ✓ Found via search: {album_art_url[:50]}...")
|
logger.info(f" Found via search: {album_art_url[:50]}...")
|
||||||
return album_art_url
|
return album_art_url
|
||||||
except Exception as search_error:
|
except Exception as search_error:
|
||||||
logger.warning(f" ❌ Search error: {search_error}")
|
logger.warning(f" Search error: {search_error}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
logger.warning(f" ⚠️ No album art found on Spotify")
|
logger.warning(f" No album art found on Spotify")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f" ❌ Could not import Spotify client: {e}")
|
logger.warning(f" Could not import Spotify client: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ❌ Error fetching album art: {e}")
|
logger.warning(f" Error fetching album art: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
@ -925,10 +925,10 @@ class YouTubeClient:
|
||||||
|
|
||||||
# Skip if already exists
|
# Skip if already exists
|
||||||
if cover_path.exists():
|
if cover_path.exists():
|
||||||
logger.info(f" 📷 cover.jpg already exists")
|
logger.info(f" cover.jpg already exists")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f" 📷 Downloading cover.jpg to album folder...")
|
logger.info(f" Downloading cover.jpg to album folder...")
|
||||||
|
|
||||||
# Download album art
|
# Download album art
|
||||||
response = requests.get(album_art_url, timeout=10)
|
response = requests.get(album_art_url, timeout=10)
|
||||||
|
|
@ -937,10 +937,10 @@ class YouTubeClient:
|
||||||
# Save to file
|
# Save to file
|
||||||
cover_path.write_bytes(response.content)
|
cover_path.write_bytes(response.content)
|
||||||
|
|
||||||
logger.info(f" ✅ Saved cover.jpg ({len(response.content) // 1024} KB)")
|
logger.info(f" Saved cover.jpg ({len(response.content) // 1024} KB)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ⚠️ Could not save cover.jpg: {e}")
|
logger.warning(f" Could not save cover.jpg: {e}")
|
||||||
|
|
||||||
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
|
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
|
||||||
"""
|
"""
|
||||||
|
|
@ -952,10 +952,10 @@ class YouTubeClient:
|
||||||
from core.lyrics_client import lyrics_client
|
from core.lyrics_client import lyrics_client
|
||||||
|
|
||||||
if not lyrics_client.api:
|
if not lyrics_client.api:
|
||||||
logger.debug(f" 🎵 LRClib API not available - skipping lyrics")
|
logger.debug(f" LRClib API not available - skipping lyrics")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f" 🎵 Fetching lyrics from LRClib...")
|
logger.info(f" Fetching lyrics from LRClib...")
|
||||||
|
|
||||||
# Get track metadata
|
# Get track metadata
|
||||||
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
||||||
|
|
@ -973,14 +973,14 @@ class YouTubeClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f" ✅ Created .lrc lyrics file")
|
logger.info(f" Created .lrc lyrics file")
|
||||||
else:
|
else:
|
||||||
logger.info(f" 🎵 No lyrics found on LRClib")
|
logger.info(f" No lyrics found on LRClib")
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics")
|
logger.debug(f" lyrics_client not available - skipping lyrics")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" ⚠️ Could not create lyrics file: {e}")
|
logger.warning(f" Could not create lyrics file: {e}")
|
||||||
|
|
||||||
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
|
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -994,7 +994,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
Path to downloaded file, or None if failed
|
Path to downloaded file, or None if failed
|
||||||
"""
|
"""
|
||||||
logger.info(f"🎯 Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
|
logger.info(f"Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
|
||||||
|
|
||||||
# Generate search query
|
# Generate search query
|
||||||
query = f"{spotify_track.artists[0]} {spotify_track.name}"
|
query = f"{spotify_track.artists[0]} {spotify_track.name}"
|
||||||
|
|
@ -1003,19 +1003,19 @@ class YouTubeClient:
|
||||||
results = self.search(query, max_results=10)
|
results = self.search(query, max_results=10)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
logger.error(f"❌ No YouTube results found for query: {query}")
|
logger.error(f"No YouTube results found for query: {query}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Find best matches
|
# Find best matches
|
||||||
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
|
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
logger.error(f"❌ No matches above {min_confidence} confidence threshold")
|
logger.error(f"No matches above {min_confidence} confidence threshold")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try downloading best match
|
# Try downloading best match
|
||||||
best_match = matches[0]
|
best_match = matches[0]
|
||||||
logger.info(f"🎯 Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
|
logger.info(f"Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
|
||||||
|
|
||||||
downloaded_file = self.download(best_match, spotify_track)
|
downloaded_file = self.download(best_match, spotify_track)
|
||||||
|
|
||||||
|
|
@ -1030,7 +1030,7 @@ def test_youtube_download():
|
||||||
"""Test the YouTube download flow with a curated playlist"""
|
"""Test the YouTube download flow with a curated playlist"""
|
||||||
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print("🎵 YouTube Download Test - Curated Playlist (5 Tracks)")
|
print("YouTube Download Test - Curated Playlist (5 Tracks)")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -1082,7 +1082,7 @@ def test_youtube_download():
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
print("🎧 Curated Playlist - Testing Diverse Genres:")
|
print("Curated Playlist - Testing Diverse Genres:")
|
||||||
print()
|
print()
|
||||||
for i, track in enumerate(test_playlist, 1):
|
for i, track in enumerate(test_playlist, 1):
|
||||||
print(f" {i}. {track.artists[0]:20s} - {track.name}")
|
print(f" {i}. {track.artists[0]:20s} - {track.name}")
|
||||||
|
|
@ -1133,7 +1133,7 @@ def test_youtube_download():
|
||||||
'error': None
|
'error': None
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"\n✅ SUCCESS - Downloaded in {track_duration:.1f}s")
|
print(f"\nSUCCESS - Downloaded in {track_duration:.1f}s")
|
||||||
print(f" File: {Path(downloaded_file).name}")
|
print(f" File: {Path(downloaded_file).name}")
|
||||||
print(f" Size: {file_size:.2f} MB")
|
print(f" Size: {file_size:.2f} MB")
|
||||||
else:
|
else:
|
||||||
|
|
@ -1148,13 +1148,13 @@ def test_youtube_download():
|
||||||
'error': 'Download failed or no matches found'
|
'error': 'Download failed or no matches found'
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"\n❌ FAILED - No suitable match found")
|
print(f"\nFAILED - No suitable match found")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
track_end_time = datetime.now()
|
track_end_time = datetime.now()
|
||||||
track_duration = (track_end_time - track_start_time).total_seconds()
|
track_duration = (track_end_time - track_start_time).total_seconds()
|
||||||
|
|
||||||
print(f"\n❌ ERROR: {e}")
|
print(f"\nERROR: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
@ -1177,7 +1177,7 @@ def test_youtube_download():
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
print("\n\n" + "=" * 80)
|
print("\n\n" + "=" * 80)
|
||||||
print("📊 FINAL SUMMARY REPORT")
|
print("FINAL SUMMARY REPORT")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -1185,7 +1185,7 @@ def test_youtube_download():
|
||||||
failed = [r for r in results if not r['success']]
|
failed = [r for r in results if not r['success']]
|
||||||
|
|
||||||
print(f"⏱️ Total Time: {total_duration:.1f}s ({total_duration/60:.1f} minutes)")
|
print(f"⏱️ Total Time: {total_duration:.1f}s ({total_duration/60:.1f} minutes)")
|
||||||
print(f"✅ Success Rate: {len(successful)}/{len(results)} ({len(successful)/len(results)*100:.1f}%)")
|
print(f"Success Rate: {len(successful)}/{len(results)} ({len(successful)/len(results)*100:.1f}%)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if successful:
|
if successful:
|
||||||
|
|
@ -1193,7 +1193,7 @@ def test_youtube_download():
|
||||||
avg_time = sum(r['duration_seconds'] for r in successful) / len(successful)
|
avg_time = sum(r['duration_seconds'] for r in successful) / len(successful)
|
||||||
|
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
print("✅ SUCCESSFUL DOWNLOADS:")
|
print("SUCCESSFUL DOWNLOADS:")
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
for i, result in enumerate(successful, 1):
|
for i, result in enumerate(successful, 1):
|
||||||
print(f"\n{i}. {result['artist']} - {result['track']}")
|
print(f"\n{i}. {result['artist']} - {result['track']}")
|
||||||
|
|
@ -1203,13 +1203,13 @@ def test_youtube_download():
|
||||||
print(f" Time: {result['duration_seconds']:.1f}s")
|
print(f" Time: {result['duration_seconds']:.1f}s")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"📦 Total Downloaded: {total_size:.2f} MB")
|
print(f"Total Downloaded: {total_size:.2f} MB")
|
||||||
print(f"⚡ Average Download Time: {avg_time:.1f}s per track")
|
print(f"Average Download Time: {avg_time:.1f}s per track")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
print("❌ FAILED DOWNLOADS:")
|
print("FAILED DOWNLOADS:")
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
for i, result in enumerate(failed, 1):
|
for i, result in enumerate(failed, 1):
|
||||||
print(f"\n{i}. {result['artist']} - {result['track']}")
|
print(f"\n{i}. {result['artist']} - {result['track']}")
|
||||||
|
|
@ -1219,7 +1219,7 @@ def test_youtube_download():
|
||||||
# File list for easy access
|
# File list for easy access
|
||||||
if successful:
|
if successful:
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
print("📁 DOWNLOAD LOCATION:")
|
print("DOWNLOAD LOCATION:")
|
||||||
print("─" * 80)
|
print("─" * 80)
|
||||||
print(f"\n{yt_client.download_path.absolute()}\n")
|
print(f"\n{yt_client.download_path.absolute()}\n")
|
||||||
print("Files:")
|
print("Files:")
|
||||||
|
|
@ -1228,13 +1228,13 @@ def test_youtube_download():
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print("🎉 Test Complete!")
|
print("Test Complete!")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Quality check reminder
|
# Quality check reminder
|
||||||
if successful:
|
if successful:
|
||||||
print("📝 Next Steps:")
|
print("Next Steps:")
|
||||||
print(" 1. Listen to downloaded files to verify quality")
|
print(" 1. Listen to downloaded files to verify quality")
|
||||||
print(" 2. Check metadata tags in your music player")
|
print(" 2. Check metadata tags in your music player")
|
||||||
print(" 3. Compare with Soulseek downloads if available")
|
print(" 3. Compare with Soulseek downloads if available")
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class DatabaseUpdaterWidget(QFrame):
|
||||||
info_label.setWordWrap(True)
|
info_label.setWordWrap(True)
|
||||||
|
|
||||||
# Recommendation label
|
# Recommendation label
|
||||||
self.recommendation_label = QLabel("💡 Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
self.recommendation_label = QLabel("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
||||||
self.recommendation_label.setFont(QFont("Arial", 9))
|
self.recommendation_label.setFont(QFont("Arial", 9))
|
||||||
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
||||||
self.recommendation_label.setWordWrap(True)
|
self.recommendation_label.setWordWrap(True)
|
||||||
|
|
@ -389,8 +389,8 @@ class DatabaseUpdaterWidget(QFrame):
|
||||||
def _update_recommendation_urgency(self, urgent: bool = False):
|
def _update_recommendation_urgency(self, urgent: bool = False):
|
||||||
"""Update the recommendation label styling based on urgency"""
|
"""Update the recommendation label styling based on urgency"""
|
||||||
if urgent:
|
if urgent:
|
||||||
self.recommendation_label.setText("⚠️ Recommended: Run a Full Refresh - it's been over 2 weeks!")
|
self.recommendation_label.setText("Recommended: Run a Full Refresh - it's been over 2 weeks!")
|
||||||
self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;")
|
self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;")
|
||||||
else:
|
else:
|
||||||
self.recommendation_label.setText("💡 Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
self.recommendation_label.setText("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
||||||
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
||||||
|
|
@ -62,17 +62,17 @@ class Toast(QWidget):
|
||||||
def apply_styling(self):
|
def apply_styling(self):
|
||||||
"""Apply styling based on toast type"""
|
"""Apply styling based on toast type"""
|
||||||
if self.toast_type == ToastType.SUCCESS:
|
if self.toast_type == ToastType.SUCCESS:
|
||||||
icon = "✅"
|
icon = ""
|
||||||
accent_color = "#1db954" # Spotify green
|
accent_color = "#1db954" # Spotify green
|
||||||
bg_color = "rgba(29, 185, 84, 0.15)"
|
bg_color = "rgba(29, 185, 84, 0.15)"
|
||||||
border_color = "rgba(29, 185, 84, 0.3)"
|
border_color = "rgba(29, 185, 84, 0.3)"
|
||||||
elif self.toast_type == ToastType.ERROR:
|
elif self.toast_type == ToastType.ERROR:
|
||||||
icon = "❌"
|
icon = ""
|
||||||
accent_color = "#f04747"
|
accent_color = "#f04747"
|
||||||
bg_color = "rgba(240, 71, 71, 0.15)"
|
bg_color = "rgba(240, 71, 71, 0.15)"
|
||||||
border_color = "rgba(240, 71, 71, 0.3)"
|
border_color = "rgba(240, 71, 71, 0.3)"
|
||||||
elif self.toast_type == ToastType.WARNING:
|
elif self.toast_type == ToastType.WARNING:
|
||||||
icon = "⚠️"
|
icon = ""
|
||||||
accent_color = "#ffa500"
|
accent_color = "#ffa500"
|
||||||
bg_color = "rgba(255, 165, 0, 0.15)"
|
bg_color = "rgba(255, 165, 0, 0.15)"
|
||||||
border_color = "rgba(255, 165, 0, 0.3)"
|
border_color = "rgba(255, 165, 0, 0.3)"
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class VersionInfoModal(QDialog):
|
||||||
|
|
||||||
# WebUI Transformation
|
# WebUI Transformation
|
||||||
webui_section = self.create_feature_section(
|
webui_section = self.create_feature_section(
|
||||||
"🌐 Complete WebUI Transformation",
|
"Complete WebUI Transformation",
|
||||||
"SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface",
|
"SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface",
|
||||||
[
|
[
|
||||||
"• Full transition from PyQt6 desktop application to responsive web interface",
|
"• Full transition from PyQt6 desktop application to responsive web interface",
|
||||||
|
|
@ -131,7 +131,7 @@ class VersionInfoModal(QDialog):
|
||||||
|
|
||||||
# Docker Support
|
# Docker Support
|
||||||
docker_section = self.create_feature_section(
|
docker_section = self.create_feature_section(
|
||||||
"🐳 Docker Container Support",
|
"Docker Container Support",
|
||||||
"Complete containerization with Docker for easy deployment and scalability",
|
"Complete containerization with Docker for easy deployment and scalability",
|
||||||
[
|
[
|
||||||
"• Pre-built Docker images available for instant deployment",
|
"• Pre-built Docker images available for instant deployment",
|
||||||
|
|
@ -147,7 +147,7 @@ class VersionInfoModal(QDialog):
|
||||||
|
|
||||||
# Enhanced Music Management
|
# Enhanced Music Management
|
||||||
music_section = self.create_feature_section(
|
music_section = self.create_feature_section(
|
||||||
"🎵 Enhanced Music Management",
|
"Enhanced Music Management",
|
||||||
"All beloved features preserved and enhanced with new web-based capabilities",
|
"All beloved features preserved and enhanced with new web-based capabilities",
|
||||||
[
|
[
|
||||||
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
|
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
|
||||||
|
|
@ -163,7 +163,7 @@ class VersionInfoModal(QDialog):
|
||||||
|
|
||||||
# Performance & Reliability
|
# Performance & Reliability
|
||||||
performance_section = self.create_feature_section(
|
performance_section = self.create_feature_section(
|
||||||
"🚀 Performance & Reliability",
|
"Performance & Reliability",
|
||||||
"Significant improvements in speed, stability, and resource efficiency",
|
"Significant improvements in speed, stability, and resource efficiency",
|
||||||
[
|
[
|
||||||
"• Asynchronous processing for improved responsiveness",
|
"• Asynchronous processing for improved responsiveness",
|
||||||
|
|
@ -234,7 +234,7 @@ class VersionInfoModal(QDialog):
|
||||||
|
|
||||||
# Usage note if provided
|
# Usage note if provided
|
||||||
if usage_note:
|
if usage_note:
|
||||||
usage_label = QLabel(f"💡 {usage_note}")
|
usage_label = QLabel(f"{usage_note}")
|
||||||
usage_label.setFont(QFont("SF Pro Text", 10))
|
usage_label.setFont(QFont("SF Pro Text", 10))
|
||||||
usage_label.setStyleSheet("""
|
usage_label.setStyleSheet("""
|
||||||
color: #1ed760;
|
color: #1ed760;
|
||||||
|
|
|
||||||
|
|
@ -483,7 +483,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
|
|
||||||
# Search bar
|
# Search bar
|
||||||
self.search_bar = QLineEdit()
|
self.search_bar = QLineEdit()
|
||||||
self.search_bar.setPlaceholderText("🔍 Search all artists...")
|
self.search_bar.setPlaceholderText("Search all artists...")
|
||||||
self.search_bar.setFixedHeight(32)
|
self.search_bar.setFixedHeight(32)
|
||||||
self.search_bar.setStyleSheet("""
|
self.search_bar.setStyleSheet("""
|
||||||
QLineEdit {
|
QLineEdit {
|
||||||
|
|
@ -675,7 +675,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
def get_artist_status_icon(self, artist):
|
def get_artist_status_icon(self, artist):
|
||||||
"""Determine the appropriate status icon and color for an artist based on scan history"""
|
"""Determine the appropriate status icon and color for an artist based on scan history"""
|
||||||
if not artist.last_scan_timestamp:
|
if not artist.last_scan_timestamp:
|
||||||
return "⚪", "#888888" # Not scanned yet (gray circle)
|
return "", "#888888" # Not scanned yet (gray circle)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -693,13 +693,13 @@ class WatchlistStatusModal(QDialog):
|
||||||
# If scanned within the last 24 hours, show as up to date
|
# If scanned within the last 24 hours, show as up to date
|
||||||
# If older, show as potentially stale but still scanned
|
# If older, show as potentially stale but still scanned
|
||||||
if hours_ago <= 24:
|
if hours_ago <= 24:
|
||||||
return "✓", "#4caf50" # Recently up to date (bright green)
|
return "", "#4caf50" # Recently up to date (bright green)
|
||||||
else:
|
else:
|
||||||
return "✓", "#888888" # Scanned but older (gray checkmark)
|
return "", "#888888" # Scanned but older (gray checkmark)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback if datetime parsing fails
|
# Fallback if datetime parsing fails
|
||||||
return "✓", "#4caf50" # Default to up to date
|
return "", "#4caf50" # Default to up to date
|
||||||
|
|
||||||
def create_artist_card(self, artist: WatchlistArtist) -> QFrame:
|
def create_artist_card(self, artist: WatchlistArtist) -> QFrame:
|
||||||
"""Create a professional artist card widget"""
|
"""Create a professional artist card widget"""
|
||||||
|
|
@ -773,7 +773,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
info_layout.addWidget(sync_label)
|
info_layout.addWidget(sync_label)
|
||||||
|
|
||||||
# Delete button with modern styling
|
# Delete button with modern styling
|
||||||
delete_button = QPushButton("✕")
|
delete_button = QPushButton("")
|
||||||
delete_button.setFixedSize(28, 28)
|
delete_button.setFixedSize(28, 28)
|
||||||
delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||||
delete_button.setStyleSheet("""
|
delete_button.setStyleSheet("""
|
||||||
|
|
@ -870,7 +870,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
if item and item.widget():
|
if item and item.widget():
|
||||||
card = item.widget()
|
card = item.widget()
|
||||||
if hasattr(card, 'status_indicator'):
|
if hasattr(card, 'status_indicator'):
|
||||||
card.status_indicator.setText("⚪") # Not scanned yet
|
card.status_indicator.setText("") # Not scanned yet
|
||||||
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
|
||||||
|
|
||||||
# Use shared scan worker so it persists across modal close/open
|
# Use shared scan worker so it persists across modal close/open
|
||||||
|
|
@ -945,7 +945,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
# Find artist by name (we don't have ID in signal)
|
# Find artist by name (we don't have ID in signal)
|
||||||
for artist in self.current_artists:
|
for artist in self.current_artists:
|
||||||
if artist.artist_name == artist_name:
|
if artist.artist_name == artist_name:
|
||||||
card.status_indicator.setText("🔍") # Scanning
|
card.status_indicator.setText("") # Scanning
|
||||||
card.status_indicator.setStyleSheet("color: #ffc107; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #ffc107; border: none; background: transparent;")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -1018,13 +1018,13 @@ class WatchlistStatusModal(QDialog):
|
||||||
if artist.artist_name == artist_name:
|
if artist.artist_name == artist_name:
|
||||||
if success:
|
if success:
|
||||||
if new_tracks > 0:
|
if new_tracks > 0:
|
||||||
card.status_indicator.setText("⚡") # New tracks found
|
card.status_indicator.setText("") # New tracks found
|
||||||
card.status_indicator.setStyleSheet("color: #1db954; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #1db954; border: none; background: transparent;")
|
||||||
else:
|
else:
|
||||||
card.status_indicator.setText("✓") # Up to date
|
card.status_indicator.setText("") # Up to date
|
||||||
card.status_indicator.setStyleSheet("color: #4caf50; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #4caf50; border: none; background: transparent;")
|
||||||
else:
|
else:
|
||||||
card.status_indicator.setText("❌") # Error
|
card.status_indicator.setText("") # Error
|
||||||
card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -1083,7 +1083,7 @@ class WatchlistStatusModal(QDialog):
|
||||||
if item and item.widget():
|
if item and item.widget():
|
||||||
card = item.widget()
|
card = item.widget()
|
||||||
if hasattr(card, 'status_indicator'):
|
if hasattr(card, 'status_indicator'):
|
||||||
card.status_indicator.setText("⚪") # Not scanned yet
|
card.status_indicator.setText("") # Not scanned yet
|
||||||
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
|
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
|
||||||
|
|
||||||
def on_background_scan_completed(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int):
|
def on_background_scan_completed(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int):
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -381,10 +381,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
title_section.addWidget(subtitle)
|
title_section.addWidget(subtitle)
|
||||||
|
|
||||||
dashboard_layout = QHBoxLayout()
|
dashboard_layout = QHBoxLayout()
|
||||||
self.total_card = self.create_compact_counter_card("📀 Total", "0", "#1db954")
|
self.total_card = self.create_compact_counter_card("Total", "0", "#1db954")
|
||||||
self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50")
|
self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50")
|
||||||
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
||||||
self.downloaded_card = self.create_compact_counter_card("✅ Downloaded", "0", "#4CAF50")
|
self.downloaded_card = self.create_compact_counter_card("Downloaded", "0", "#4CAF50")
|
||||||
dashboard_layout.addWidget(self.total_card)
|
dashboard_layout.addWidget(self.total_card)
|
||||||
dashboard_layout.addWidget(self.matched_card)
|
dashboard_layout.addWidget(self.matched_card)
|
||||||
dashboard_layout.addWidget(self.download_card)
|
dashboard_layout.addWidget(self.download_card)
|
||||||
|
|
@ -421,7 +421,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;")
|
progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;")
|
||||||
layout = QVBoxLayout(progress_frame)
|
layout = QVBoxLayout(progress_frame)
|
||||||
analysis_container = QVBoxLayout()
|
analysis_container = QVBoxLayout()
|
||||||
analysis_label = QLabel("🔍 Library Analysis")
|
analysis_label = QLabel("Library Analysis")
|
||||||
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||||||
self.analysis_progress = QProgressBar()
|
self.analysis_progress = QProgressBar()
|
||||||
self.analysis_progress.setFixedHeight(20)
|
self.analysis_progress.setFixedHeight(20)
|
||||||
|
|
@ -482,7 +482,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
# --- DURATION LOGIC REMOVED ---
|
# --- DURATION LOGIC REMOVED ---
|
||||||
|
|
||||||
# "Matched" is now column 2
|
# "Matched" is now column 2
|
||||||
matched_item = QTableWidgetItem("⏳ Pending")
|
matched_item = QTableWidgetItem("Pending")
|
||||||
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.track_table.setItem(i, 2, matched_item)
|
self.track_table.setItem(i, 2, matched_item)
|
||||||
|
|
||||||
|
|
@ -575,10 +575,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
cancel_button = layout.itemAt(0).widget()
|
cancel_button = layout.itemAt(0).widget()
|
||||||
if cancel_button:
|
if cancel_button:
|
||||||
cancel_button.setEnabled(False)
|
cancel_button.setEnabled(False)
|
||||||
cancel_button.setText("✓")
|
cancel_button.setText("")
|
||||||
|
|
||||||
# Update status to cancelled (column 3 for dashboard)
|
# Update status to cancelled (column 3 for dashboard)
|
||||||
self.track_table.setItem(row, 3, QTableWidgetItem("🚫 Cancelled"))
|
self.track_table.setItem(row, 3, QTableWidgetItem("Cancelled"))
|
||||||
|
|
||||||
# Add to cancelled tracks set
|
# Add to cancelled tracks set
|
||||||
if not hasattr(self, 'cancelled_tracks'):
|
if not hasattr(self, 'cancelled_tracks'):
|
||||||
|
|
@ -586,7 +586,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
self.cancelled_tracks.add(row)
|
self.cancelled_tracks.add(row)
|
||||||
|
|
||||||
track = self.wishlist_tracks[row]
|
track = self.wishlist_tracks[row]
|
||||||
print(f"🚫 Track cancelled: {track.name} (row {row})")
|
print(f"Track cancelled: {track.name} (row {row})")
|
||||||
|
|
||||||
# If downloads are active, also handle active download cancellation
|
# If downloads are active, also handle active download cancellation
|
||||||
download_index = None
|
download_index = None
|
||||||
|
|
@ -596,7 +596,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
for download in self.active_downloads:
|
for download in self.active_downloads:
|
||||||
if download.get('table_index') == row:
|
if download.get('table_index') == row:
|
||||||
download_index = download.get('download_index', row)
|
download_index = download.get('download_index', row)
|
||||||
print(f"🚫 Found active download {download_index} for cancelled track")
|
print(f"Found active download {download_index} for cancelled track")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check parallel_search_tracking for download index
|
# Check parallel_search_tracking for download index
|
||||||
|
|
@ -604,23 +604,23 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
for idx, track_info in self.parallel_search_tracking.items():
|
for idx, track_info in self.parallel_search_tracking.items():
|
||||||
if track_info.get('table_index') == row:
|
if track_info.get('table_index') == row:
|
||||||
download_index = idx
|
download_index = idx
|
||||||
print(f"🚫 Found parallel tracking {download_index} for cancelled track")
|
print(f"Found parallel tracking {download_index} for cancelled track")
|
||||||
break
|
break
|
||||||
|
|
||||||
# If we found an active download, trigger completion to free up the worker
|
# If we found an active download, trigger completion to free up the worker
|
||||||
if download_index is not None and hasattr(self, 'on_parallel_track_completed'):
|
if download_index is not None and hasattr(self, 'on_parallel_track_completed'):
|
||||||
print(f"🚫 Triggering completion for active download {download_index}")
|
print(f"Triggering completion for active download {download_index}")
|
||||||
self.on_parallel_track_completed(download_index, success=False)
|
self.on_parallel_track_completed(download_index, success=False)
|
||||||
|
|
||||||
def create_buttons(self):
|
def create_buttons(self):
|
||||||
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
|
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
|
||||||
layout = QHBoxLayout(button_frame)
|
layout = QHBoxLayout(button_frame)
|
||||||
self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches")
|
self.correct_failed_btn = QPushButton("Correct Failed Matches")
|
||||||
self.correct_failed_btn.setFixedWidth(220)
|
self.correct_failed_btn.setFixedWidth(220)
|
||||||
self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }")
|
self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }")
|
||||||
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
||||||
self.correct_failed_btn.hide()
|
self.correct_failed_btn.hide()
|
||||||
self.clear_wishlist_btn = QPushButton("🗑️ Clear Wishlist")
|
self.clear_wishlist_btn = QPushButton("Clear Wishlist")
|
||||||
self.clear_wishlist_btn.setFixedSize(150, 40)
|
self.clear_wishlist_btn.setFixedSize(150, 40)
|
||||||
self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }")
|
self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }")
|
||||||
self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked)
|
self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked)
|
||||||
|
|
@ -701,7 +701,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
self.analysis_progress.setValue(track_index)
|
self.analysis_progress.setValue(track_index)
|
||||||
row_index = track_index - 1
|
row_index = track_index - 1
|
||||||
if result.exists_in_plex:
|
if result.exists_in_plex:
|
||||||
matched_text = f"✅ Found ({result.confidence:.1f})"
|
matched_text = f"Found ({result.confidence:.1f})"
|
||||||
self.matched_tracks_count += 1
|
self.matched_tracks_count += 1
|
||||||
self.matched_count_label.setText(str(self.matched_tracks_count))
|
self.matched_count_label.setText(str(self.matched_tracks_count))
|
||||||
|
|
||||||
|
|
@ -713,7 +713,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.")
|
logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
matched_text = "❌ Missing"
|
matched_text = "Missing"
|
||||||
self.tracks_to_download_count += 1
|
self.tracks_to_download_count += 1
|
||||||
self.download_count_label.setText(str(self.tracks_to_download_count))
|
self.download_count_label.setText(str(self.tracks_to_download_count))
|
||||||
# Add cancel button for missing tracks only
|
# Add cancel button for missing tracks only
|
||||||
|
|
@ -764,13 +764,13 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
if track_index != -1:
|
if track_index != -1:
|
||||||
# Skip if track was cancelled
|
# Skip if track was cancelled
|
||||||
if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks:
|
if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks:
|
||||||
print(f"🚫 Skipping cancelled track at index {track_index}: {track.name}")
|
print(f"Skipping cancelled track at index {track_index}: {track.name}")
|
||||||
self.download_queue_index += 1
|
self.download_queue_index += 1
|
||||||
self.completed_downloads += 1
|
self.completed_downloads += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# FIX: Changed column index from 4 to 3 to target the "Status" column.
|
# FIX: Changed column index from 4 to 3 to target the "Status" column.
|
||||||
self.track_table.setItem(track_index, 3, QTableWidgetItem("🔍 Searching..."))
|
self.track_table.setItem(track_index, 3, QTableWidgetItem("Searching..."))
|
||||||
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
||||||
self.active_parallel_downloads += 1
|
self.active_parallel_downloads += 1
|
||||||
self.download_queue_index += 1
|
self.download_queue_index += 1
|
||||||
|
|
@ -940,18 +940,18 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
if not next_candidate:
|
if not next_candidate:
|
||||||
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
||||||
return
|
return
|
||||||
self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"🔄 Retrying ({track_info['retry_count']})..."))
|
self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"Retrying ({track_info['retry_count']})..."))
|
||||||
self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index)
|
self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index)
|
||||||
|
|
||||||
def on_parallel_track_completed(self, download_index, success):
|
def on_parallel_track_completed(self, download_index, success):
|
||||||
if not hasattr(self, 'parallel_search_tracking'):
|
if not hasattr(self, 'parallel_search_tracking'):
|
||||||
print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}")
|
print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}")
|
||||||
return
|
return
|
||||||
track_info = self.parallel_search_tracking.get(download_index)
|
track_info = self.parallel_search_tracking.get(download_index)
|
||||||
if not track_info or track_info.get('completed', False): return
|
if not track_info or track_info.get('completed', False): return
|
||||||
track_info['completed'] = True
|
track_info['completed'] = True
|
||||||
if success:
|
if success:
|
||||||
self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("✅ Downloaded"))
|
self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("Downloaded"))
|
||||||
# Hide cancel button since track is now downloaded
|
# Hide cancel button since track is now downloaded
|
||||||
self.hide_cancel_button_for_row(track_info['table_index'])
|
self.hide_cancel_button_for_row(track_info['table_index'])
|
||||||
self.downloaded_tracks_count += 1
|
self.downloaded_tracks_count += 1
|
||||||
|
|
@ -966,10 +966,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
# Check if track was cancelled (don't overwrite cancelled status)
|
# Check if track was cancelled (don't overwrite cancelled status)
|
||||||
table_index = track_info['table_index']
|
table_index = track_info['table_index']
|
||||||
current_status = self.track_table.item(table_index, 3)
|
current_status = self.track_table.item(table_index, 3)
|
||||||
if current_status and "🚫 Cancelled" in current_status.text():
|
if current_status and "Cancelled" in current_status.text():
|
||||||
print(f"🔧 Track {download_index} was cancelled - preserving cancelled status")
|
print(f"Track {download_index} was cancelled - preserving cancelled status")
|
||||||
else:
|
else:
|
||||||
self.track_table.setItem(table_index, 3, QTableWidgetItem("❌ Failed"))
|
self.track_table.setItem(table_index, 3, QTableWidgetItem("Failed"))
|
||||||
if track_info not in self.permanently_failed_tracks:
|
if track_info not in self.permanently_failed_tracks:
|
||||||
self.permanently_failed_tracks.append(track_info)
|
self.permanently_failed_tracks.append(track_info)
|
||||||
self.failed_downloads += 1
|
self.failed_downloads += 1
|
||||||
|
|
@ -986,7 +986,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
def update_failed_matches_button(self):
|
def update_failed_matches_button(self):
|
||||||
count = len(self.permanently_failed_tracks)
|
count = len(self.permanently_failed_tracks)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
self.correct_failed_btn.setText(f"🔧 Correct {count} Failed Match{'es' if count > 1 else ''}")
|
self.correct_failed_btn.setText(f"Correct {count} Failed Match{'es' if count > 1 else ''}")
|
||||||
self.correct_failed_btn.show()
|
self.correct_failed_btn.show()
|
||||||
else:
|
else:
|
||||||
self.correct_failed_btn.hide()
|
self.correct_failed_btn.hide()
|
||||||
|
|
@ -1033,8 +1033,8 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
status_item = self.track_table.item(cancelled_row, 3)
|
status_item = self.track_table.item(cancelled_row, 3)
|
||||||
current_status = status_item.text() if status_item else ""
|
current_status = status_item.text() if status_item else ""
|
||||||
|
|
||||||
if "✅ Downloaded" in current_status:
|
if "Downloaded" in current_status:
|
||||||
print(f"🚫 Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
|
print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
|
||||||
else:
|
else:
|
||||||
cancelled_track_info = {
|
cancelled_track_info = {
|
||||||
'download_index': cancelled_row,
|
'download_index': cancelled_row,
|
||||||
|
|
@ -1048,9 +1048,9 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
# Check if not already in permanently_failed_tracks
|
# Check if not already in permanently_failed_tracks
|
||||||
if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks):
|
if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks):
|
||||||
self.permanently_failed_tracks.append(cancelled_track_info)
|
self.permanently_failed_tracks.append(cancelled_track_info)
|
||||||
print(f"🚫 Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition")
|
print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition")
|
||||||
else:
|
else:
|
||||||
print(f"🚫 Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition")
|
print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition")
|
||||||
|
|
||||||
wishlist_added_count = 0
|
wishlist_added_count = 0
|
||||||
if self.permanently_failed_tracks:
|
if self.permanently_failed_tracks:
|
||||||
|
|
@ -1061,7 +1061,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
|
||||||
|
|
||||||
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n"
|
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n"
|
||||||
if wishlist_added_count > 0:
|
if wishlist_added_count > 0:
|
||||||
final_message += f"✨ Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n"
|
final_message += f"Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n"
|
||||||
if self.permanently_failed_tracks:
|
if self.permanently_failed_tracks:
|
||||||
final_message += "You can also manually correct failed downloads."
|
final_message += "You can also manually correct failed downloads."
|
||||||
else:
|
else:
|
||||||
|
|
@ -1322,7 +1322,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
|
||||||
"""Run the download with detailed status updates"""
|
"""Run the download with detailed status updates"""
|
||||||
try:
|
try:
|
||||||
# Update status: Starting search
|
# Update status: Starting search
|
||||||
self.signals.status_updated.emit(self.download_index, "🔍 Searching...")
|
self.signals.status_updated.emit(self.download_index, "Searching...")
|
||||||
|
|
||||||
# Use async method in sync context
|
# Use async method in sync context
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
|
@ -1330,7 +1330,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update status: Found candidates, analyzing
|
# Update status: Found candidates, analyzing
|
||||||
self.signals.status_updated.emit(self.download_index, "🔎 Analyzing results...")
|
self.signals.status_updated.emit(self.download_index, "Analyzing results...")
|
||||||
|
|
||||||
# Use the enhanced search method that provides more feedback
|
# Use the enhanced search method that provides more feedback
|
||||||
results = loop.run_until_complete(
|
results = loop.run_until_complete(
|
||||||
|
|
@ -1339,7 +1339,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
|
||||||
|
|
||||||
if results and len(results) > 0:
|
if results and len(results) > 0:
|
||||||
# Update status: Found candidates, starting download
|
# Update status: Found candidates, starting download
|
||||||
self.signals.status_updated.emit(self.download_index, f"📋 Found {len(results)} candidates")
|
self.signals.status_updated.emit(self.download_index, f"Found {len(results)} candidates")
|
||||||
time.sleep(0.5) # Brief pause so user can see the status
|
time.sleep(0.5) # Brief pause so user can see the status
|
||||||
|
|
||||||
# Get the best result and start download
|
# Get the best result and start download
|
||||||
|
|
@ -1369,7 +1369,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
|
||||||
"""Search for tracks with progress updates"""
|
"""Search for tracks with progress updates"""
|
||||||
try:
|
try:
|
||||||
# Emit search progress
|
# Emit search progress
|
||||||
self.signals.status_updated.emit(self.download_index, "🌐 Searching network...")
|
self.signals.status_updated.emit(self.download_index, "Searching network...")
|
||||||
|
|
||||||
# Perform the search (this would ideally use the soulseek client's search methods)
|
# Perform the search (this would ideally use the soulseek client's search methods)
|
||||||
# For now, we'll use the existing search_and_download_best method
|
# For now, we'll use the existing search_and_download_best method
|
||||||
|
|
@ -1690,7 +1690,7 @@ class MetadataUpdateWorker(QThread):
|
||||||
print(f"No albums found for artist '{artist.title}'")
|
print(f"No albums found for artist '{artist.title}'")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"🎨 Checking artwork for {len(albums)} albums by '{artist.title}'...")
|
print(f"Checking artwork for {len(albums)} albums by '{artist.title}'...")
|
||||||
|
|
||||||
for album in albums:
|
for album in albums:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1741,10 +1741,10 @@ class MetadataUpdateWorker(QThread):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_processed = updated_count + skipped_count
|
total_processed = updated_count + skipped_count
|
||||||
print(f"🎨 Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)")
|
print(f"Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)")
|
||||||
|
|
||||||
if updated_count == 0 and skipped_count == len(albums):
|
if updated_count == 0 and skipped_count == len(albums):
|
||||||
print(f" ✅ All albums already have good artwork - no Spotify API calls needed!")
|
print(f" All albums already have good artwork - no Spotify API calls needed!")
|
||||||
return updated_count
|
return updated_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1758,17 +1758,17 @@ class MetadataUpdateWorker(QThread):
|
||||||
|
|
||||||
# Check if album has any thumb at all
|
# Check if album has any thumb at all
|
||||||
if not hasattr(album, 'thumb') or not album.thumb:
|
if not hasattr(album, 'thumb') or not album.thumb:
|
||||||
if debug: print(f" 🎨 Album '{album_title}' has NO THUMB - needs update")
|
if debug: print(f" Album '{album_title}' has NO THUMB - needs update")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
thumb_url = str(album.thumb)
|
thumb_url = str(album.thumb)
|
||||||
if debug: print(f" 🔍 Album '{album_title}' artwork URL: {thumb_url}")
|
if debug: print(f" Album '{album_title}' artwork URL: {thumb_url}")
|
||||||
|
|
||||||
# CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases
|
# CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases
|
||||||
|
|
||||||
# Case 1: Completely empty or None
|
# Case 1: Completely empty or None
|
||||||
if not thumb_url or thumb_url.strip() == '':
|
if not thumb_url or thumb_url.strip() == '':
|
||||||
if debug: print(f" 🎨 Album '{album_title}' has empty URL - needs update")
|
if debug: print(f" Album '{album_title}' has empty URL - needs update")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Case 2: Obvious placeholder text in URL
|
# Case 2: Obvious placeholder text in URL
|
||||||
|
|
@ -1784,20 +1784,20 @@ class MetadataUpdateWorker(QThread):
|
||||||
thumb_lower = thumb_url.lower()
|
thumb_lower = thumb_url.lower()
|
||||||
for placeholder in obvious_placeholders:
|
for placeholder in obvious_placeholders:
|
||||||
if placeholder in thumb_lower:
|
if placeholder in thumb_lower:
|
||||||
if debug: print(f" 🎨 Album '{album_title}' has obvious placeholder ({placeholder}) - needs update")
|
if debug: print(f" Album '{album_title}' has obvious placeholder ({placeholder}) - needs update")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Case 3: Extremely short URLs (likely broken)
|
# Case 3: Extremely short URLs (likely broken)
|
||||||
if len(thumb_url) < 20:
|
if len(thumb_url) < 20:
|
||||||
if debug: print(f" 🎨 Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update")
|
if debug: print(f" Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# OTHERWISE: Assume it has valid artwork and SKIP updating
|
# OTHERWISE: Assume it has valid artwork and SKIP updating
|
||||||
if debug: print(f" ✅ Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)")
|
if debug: print(f" Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if debug: print(f" ❌ Error checking artwork for album '{album_title}': {e}")
|
if debug: print(f" Error checking artwork for album '{album_title}': {e}")
|
||||||
# If we can't check, be conservative and skip updating
|
# If we can't check, be conservative and skip updating
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -1819,9 +1819,9 @@ class MetadataUpdateWorker(QThread):
|
||||||
# Upload using media client
|
# Upload using media client
|
||||||
success = self.media_client.update_album_poster(album, image_data)
|
success = self.media_client.update_album_poster(album, image_data)
|
||||||
if success:
|
if success:
|
||||||
print(f"✅ Updated artwork for album '{album_title}'")
|
print(f"Updated artwork for album '{album_title}'")
|
||||||
else:
|
else:
|
||||||
print(f"❌ Failed to upload artwork for album '{album_title}'")
|
print(f"Failed to upload artwork for album '{album_title}'")
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
|
@ -1980,7 +1980,7 @@ class DashboardDataProvider(QObject):
|
||||||
self.session_completed_downloads += 1
|
self.session_completed_downloads += 1
|
||||||
|
|
||||||
# Emit signal for activity feed with specific track info
|
# Emit signal for activity feed with specific track info
|
||||||
self.activity_item_added.emit("📥", "Download Complete", f"'{title}' by {artist}", "Now")
|
self.activity_item_added.emit("", "Download Complete", f"'{title}' by {artist}", "Now")
|
||||||
|
|
||||||
def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""):
|
def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""):
|
||||||
if service in self.service_status:
|
if service in self.service_status:
|
||||||
|
|
@ -2734,7 +2734,7 @@ class DashboardPage(QWidget):
|
||||||
self.scan_manager = MediaScanManager(delay_seconds=60)
|
self.scan_manager = MediaScanManager(delay_seconds=60)
|
||||||
# Add automatic incremental database update after scan completion
|
# Add automatic incremental database update after scan completion
|
||||||
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
|
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
|
||||||
logger.info("✅ MediaScanManager initialized for Dashboard wishlist modal")
|
logger.info("MediaScanManager initialized for Dashboard wishlist modal")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize MediaScanManager: {e}")
|
logger.error(f"Failed to initialize MediaScanManager: {e}")
|
||||||
|
|
||||||
|
|
@ -2792,7 +2792,7 @@ class DashboardPage(QWidget):
|
||||||
return
|
return
|
||||||
|
|
||||||
# All conditions met - start incremental update
|
# All conditions met - start incremental update
|
||||||
logger.info(f"🎵 Starting automatic incremental database update after {active_server.upper()} scan")
|
logger.info(f"Starting automatic incremental database update after {active_server.upper()} scan")
|
||||||
self._start_automatic_incremental_update()
|
self._start_automatic_incremental_update()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2843,9 +2843,9 @@ class DashboardPage(QWidget):
|
||||||
"""Handle completion of automatic database update"""
|
"""Handle completion of automatic database update"""
|
||||||
try:
|
try:
|
||||||
if successful > 0:
|
if successful > 0:
|
||||||
logger.info(f"✅ Automatic database update completed: {successful} items processed successfully")
|
logger.info(f"Automatic database update completed: {successful} items processed successfully")
|
||||||
else:
|
else:
|
||||||
logger.info("💡 Automatic database update completed - no new content found")
|
logger.info("Automatic database update completed - no new content found")
|
||||||
self.refresh_database_statistics()
|
self.refresh_database_statistics()
|
||||||
# Clean up the worker
|
# Clean up the worker
|
||||||
if hasattr(self, '_auto_database_worker'):
|
if hasattr(self, '_auto_database_worker'):
|
||||||
|
|
@ -2970,7 +2970,7 @@ class DashboardPage(QWidget):
|
||||||
buttons_layout.setSpacing(10)
|
buttons_layout.setSpacing(10)
|
||||||
|
|
||||||
# Wishlist button
|
# Wishlist button
|
||||||
self.wishlist_button = QPushButton("🎵 Wishlist (0)")
|
self.wishlist_button = QPushButton("Wishlist (0)")
|
||||||
self.wishlist_button.setFixedHeight(45)
|
self.wishlist_button.setFixedHeight(45)
|
||||||
self.wishlist_button.setFixedWidth(150)
|
self.wishlist_button.setFixedWidth(150)
|
||||||
self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked)
|
self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked)
|
||||||
|
|
@ -2997,7 +2997,7 @@ class DashboardPage(QWidget):
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Watchlist button
|
# Watchlist button
|
||||||
self.watchlist_button = QPushButton("👁️ Watchlist (0)")
|
self.watchlist_button = QPushButton("Watchlist (0)")
|
||||||
self.watchlist_button.setFixedHeight(45)
|
self.watchlist_button.setFixedHeight(45)
|
||||||
self.watchlist_button.setFixedWidth(150)
|
self.watchlist_button.setFixedWidth(150)
|
||||||
self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked)
|
self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked)
|
||||||
|
|
@ -3166,7 +3166,7 @@ class DashboardPage(QWidget):
|
||||||
self.activity_layout = activity_layout
|
self.activity_layout = activity_layout
|
||||||
|
|
||||||
# Add initial placeholder
|
# Add initial placeholder
|
||||||
placeholder_item = ActivityItem("📊", "System Started", "Dashboard initialized successfully", "Now")
|
placeholder_item = ActivityItem("", "System Started", "Dashboard initialized successfully", "Now")
|
||||||
activity_layout.addWidget(placeholder_item)
|
activity_layout.addWidget(placeholder_item)
|
||||||
|
|
||||||
layout.addWidget(header_label)
|
layout.addWidget(header_label)
|
||||||
|
|
@ -3192,7 +3192,7 @@ class DashboardPage(QWidget):
|
||||||
card.status_text.setText("Testing connection...")
|
card.status_text.setText("Testing connection...")
|
||||||
|
|
||||||
# Add activity item for test initiation
|
# Add activity item for test initiation
|
||||||
self.add_activity_item("🔍", f"Testing {service.capitalize()}", "Connection test initiated", "Now")
|
self.add_activity_item("", f"Testing {service.capitalize()}", "Connection test initiated", "Now")
|
||||||
|
|
||||||
# Start test
|
# Start test
|
||||||
self.data_provider.test_service_connection(service)
|
self.data_provider.test_service_connection(service)
|
||||||
|
|
@ -3216,7 +3216,7 @@ class DashboardPage(QWidget):
|
||||||
|
|
||||||
# Check that we have a data provider
|
# Check that we have a data provider
|
||||||
if not hasattr(self, 'data_provider'):
|
if not hasattr(self, 'data_provider'):
|
||||||
self.add_activity_item("❌", "Database Update", "Service clients not available", "Now")
|
self.add_activity_item("", "Database Update", "Service clients not available", "Now")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the active media server and check if client is available
|
# Get the active media server and check if client is available
|
||||||
|
|
@ -3224,13 +3224,13 @@ class DashboardPage(QWidget):
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
|
|
||||||
if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'):
|
if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'):
|
||||||
self.add_activity_item("❌", "Database Update", "Plex client not available", "Now")
|
self.add_activity_item("", "Database Update", "Plex client not available", "Now")
|
||||||
return
|
return
|
||||||
elif active_server == "jellyfin":
|
elif active_server == "jellyfin":
|
||||||
# Jellyfin client will be created on-demand, just verify config exists
|
# Jellyfin client will be created on-demand, just verify config exists
|
||||||
jellyfin_config = config_manager.get_jellyfin_config()
|
jellyfin_config = config_manager.get_jellyfin_config()
|
||||||
if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'):
|
if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'):
|
||||||
self.add_activity_item("❌", "Database Update", "Jellyfin not configured", "Now")
|
self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -3242,7 +3242,7 @@ class DashboardPage(QWidget):
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"Confirm Full Database Refresh",
|
"Confirm Full Database Refresh",
|
||||||
"⚠️ You've selected FULL REFRESH mode.\n\n"
|
"You've selected FULL REFRESH mode.\n\n"
|
||||||
"This will completely rebuild your database and may take several minutes.\n"
|
"This will completely rebuild your database and may take several minutes.\n"
|
||||||
"All existing data will be cleared and rebuilt from your Plex library.\n\n"
|
"All existing data will be cleared and rebuilt from your Plex library.\n\n"
|
||||||
"Are you sure you want to continue?",
|
"Are you sure you want to continue?",
|
||||||
|
|
@ -3267,7 +3267,7 @@ class DashboardPage(QWidget):
|
||||||
media_client = JellyfinClient()
|
media_client = JellyfinClient()
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown active server: {active_server}")
|
logger.error(f"Unknown active server: {active_server}")
|
||||||
self.add_activity_item("❌", "Database Update", f"Unknown server type: {active_server}", "Now")
|
self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Start the database update worker
|
# Start the database update worker
|
||||||
|
|
@ -3289,7 +3289,7 @@ class DashboardPage(QWidget):
|
||||||
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
|
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
|
||||||
update_type = "Full refresh" if full_refresh else "Incremental update"
|
update_type = "Full refresh" if full_refresh else "Incremental update"
|
||||||
server_display = active_server.title() # "Plex" or "Jellyfin"
|
server_display = active_server.title() # "Plex" or "Jellyfin"
|
||||||
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now")
|
self.add_activity_item("", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now")
|
||||||
|
|
||||||
self.database_worker.start()
|
self.database_worker.start()
|
||||||
|
|
||||||
|
|
@ -3297,7 +3297,7 @@ class DashboardPage(QWidget):
|
||||||
self.start_database_stats_refresh()
|
self.start_database_stats_refresh()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.add_activity_item("❌", "Database Update", f"Failed to start: {str(e)}", "Now")
|
self.add_activity_item("", "Database Update", f"Failed to start: {str(e)}", "Now")
|
||||||
|
|
||||||
def stop_database_update(self):
|
def stop_database_update(self):
|
||||||
"""Stop the database update process"""
|
"""Stop the database update process"""
|
||||||
|
|
@ -3308,7 +3308,7 @@ class DashboardPage(QWidget):
|
||||||
self.database_worker.terminate()
|
self.database_worker.terminate()
|
||||||
|
|
||||||
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
self.add_activity_item("⏹️", "Database Update", "Stopped database update process", "Now")
|
self.add_activity_item("", "Database Update", "Stopped database update process", "Now")
|
||||||
|
|
||||||
# Stop statistics refresh timer
|
# Stop statistics refresh timer
|
||||||
self.stop_database_stats_refresh()
|
self.stop_database_stats_refresh()
|
||||||
|
|
@ -3320,15 +3320,15 @@ class DashboardPage(QWidget):
|
||||||
def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int):
|
def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int):
|
||||||
"""Handle individual artist processing completion"""
|
"""Handle individual artist processing completion"""
|
||||||
if success:
|
if success:
|
||||||
self.add_activity_item("✅", "Artist Processed", f"'{artist_name}' - {details}", "Now")
|
self.add_activity_item("", "Artist Processed", f"'{artist_name}' - {details}", "Now")
|
||||||
else:
|
else:
|
||||||
self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
||||||
|
|
||||||
def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int):
|
def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int):
|
||||||
"""Handle database update completion"""
|
"""Handle database update completion"""
|
||||||
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks"
|
summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks"
|
||||||
self.add_activity_item("🗄️", "Database Complete", summary, "Now")
|
self.add_activity_item("", "Database Complete", summary, "Now")
|
||||||
|
|
||||||
# Stop statistics refresh timer and do final update
|
# Stop statistics refresh timer and do final update
|
||||||
self.stop_database_stats_refresh()
|
self.stop_database_stats_refresh()
|
||||||
|
|
@ -3337,7 +3337,7 @@ class DashboardPage(QWidget):
|
||||||
def on_database_error(self, error_message: str):
|
def on_database_error(self, error_message: str):
|
||||||
"""Handle database update error"""
|
"""Handle database update error"""
|
||||||
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
self.add_activity_item("❌", "Database Error", error_message, "Now")
|
self.add_activity_item("", "Database Error", error_message, "Now")
|
||||||
|
|
||||||
# Stop statistics refresh timer
|
# Stop statistics refresh timer
|
||||||
self.stop_database_stats_refresh()
|
self.stop_database_stats_refresh()
|
||||||
|
|
@ -3461,16 +3461,16 @@ class DashboardPage(QWidget):
|
||||||
if active_server == "jellyfin":
|
if active_server == "jellyfin":
|
||||||
media_client = self.data_provider.service_clients.get('jellyfin_client')
|
media_client = self.data_provider.service_clients.get('jellyfin_client')
|
||||||
if not media_client:
|
if not media_client:
|
||||||
self.add_activity_item("❌", "Metadata Update", "Jellyfin client not available", "Now")
|
self.add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
media_client = self.data_provider.service_clients.get('plex_client')
|
media_client = self.data_provider.service_clients.get('plex_client')
|
||||||
if not media_client:
|
if not media_client:
|
||||||
self.add_activity_item("❌", "Metadata Update", "Plex client not available", "Now")
|
self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.data_provider.service_clients.get('spotify_client'):
|
if not self.data_provider.service_clients.get('spotify_client'):
|
||||||
self.add_activity_item("❌", "Metadata Update", "Spotify client not available", "Now")
|
self.add_activity_item("", "Metadata Update", "Spotify client not available", "Now")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -3496,19 +3496,19 @@ class DashboardPage(QWidget):
|
||||||
# Update UI and start
|
# Update UI and start
|
||||||
if self.metadata_widget:
|
if self.metadata_widget:
|
||||||
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
|
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
|
||||||
self.add_activity_item("🎵", "Metadata Update", "Loading artists from library...", "Now")
|
self.add_activity_item("", "Metadata Update", "Loading artists from library...", "Now")
|
||||||
|
|
||||||
self.metadata_worker.start()
|
self.metadata_worker.start()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.add_activity_item("❌", "Metadata Update", f"Failed to start: {str(e)}", "Now")
|
self.add_activity_item("", "Metadata Update", f"Failed to start: {str(e)}", "Now")
|
||||||
|
|
||||||
def on_artists_loaded(self, total_artists, artists_to_process):
|
def on_artists_loaded(self, total_artists, artists_to_process):
|
||||||
"""Handle when artists are loaded and filtered"""
|
"""Handle when artists are loaded and filtered"""
|
||||||
if artists_to_process == 0:
|
if artists_to_process == 0:
|
||||||
self.add_activity_item("✅", "Metadata Update", "All artists already have good metadata", "Now")
|
self.add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now")
|
||||||
else:
|
else:
|
||||||
self.add_activity_item("🎵", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now")
|
self.add_activity_item("", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now")
|
||||||
|
|
||||||
def stop_metadata_update(self):
|
def stop_metadata_update(self):
|
||||||
"""Stop the metadata update process"""
|
"""Stop the metadata update process"""
|
||||||
|
|
@ -3520,7 +3520,7 @@ class DashboardPage(QWidget):
|
||||||
|
|
||||||
if self.metadata_widget:
|
if self.metadata_widget:
|
||||||
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
self.add_activity_item("⏹️", "Metadata Update", "Stopped metadata update process", "Now")
|
self.add_activity_item("", "Metadata Update", "Stopped metadata update process", "Now")
|
||||||
|
|
||||||
def artist_needs_processing(self, artist):
|
def artist_needs_processing(self, artist):
|
||||||
"""Check if an artist needs metadata processing using smart detection"""
|
"""Check if an artist needs metadata processing using smart detection"""
|
||||||
|
|
@ -3564,22 +3564,22 @@ class DashboardPage(QWidget):
|
||||||
def on_artist_updated(self, artist_name, success, details):
|
def on_artist_updated(self, artist_name, success, details):
|
||||||
"""Handle individual artist update completion"""
|
"""Handle individual artist update completion"""
|
||||||
if success:
|
if success:
|
||||||
self.add_activity_item("✅", "Artist Updated", f"'{artist_name}' - {details}", "Now")
|
self.add_activity_item("", "Artist Updated", f"'{artist_name}' - {details}", "Now")
|
||||||
else:
|
else:
|
||||||
self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
||||||
|
|
||||||
def on_metadata_finished(self, total_processed, successful, failed):
|
def on_metadata_finished(self, total_processed, successful, failed):
|
||||||
"""Handle metadata update completion"""
|
"""Handle metadata update completion"""
|
||||||
if self.metadata_widget:
|
if self.metadata_widget:
|
||||||
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed"
|
summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed"
|
||||||
self.add_activity_item("🎵", "Metadata Complete", summary, "Now")
|
self.add_activity_item("", "Metadata Complete", summary, "Now")
|
||||||
|
|
||||||
def on_metadata_error(self, error_message):
|
def on_metadata_error(self, error_message):
|
||||||
"""Handle metadata update error"""
|
"""Handle metadata update error"""
|
||||||
if self.metadata_widget:
|
if self.metadata_widget:
|
||||||
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
||||||
self.add_activity_item("❌", "Metadata Error", error_message, "Now")
|
self.add_activity_item("", "Metadata Error", error_message, "Now")
|
||||||
|
|
||||||
def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str):
|
def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str):
|
||||||
"""Handle service status updates from data provider"""
|
"""Handle service status updates from data provider"""
|
||||||
|
|
@ -3591,7 +3591,7 @@ class DashboardPage(QWidget):
|
||||||
self.previous_service_status[service] = connected
|
self.previous_service_status[service] = connected
|
||||||
|
|
||||||
status = "Connected" if connected else "Disconnected"
|
status = "Connected" if connected else "Disconnected"
|
||||||
icon = "✅" if connected else "❌"
|
icon = "" if connected else ""
|
||||||
self.add_activity_item(icon, f"{service.capitalize()} {status}",
|
self.add_activity_item(icon, f"{service.capitalize()} {status}",
|
||||||
f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed",
|
f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed",
|
||||||
"Now")
|
"Now")
|
||||||
|
|
@ -3676,20 +3676,20 @@ class DashboardPage(QWidget):
|
||||||
from ui.components.toast_manager import ToastType
|
from ui.components.toast_manager import ToastType
|
||||||
|
|
||||||
# Success activities that deserve toasts
|
# Success activities that deserve toasts
|
||||||
if icon == "✅" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]):
|
if icon == "" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]):
|
||||||
self.toast_manager.success(f"{title}: {subtitle}")
|
self.toast_manager.success(f"{title}: {subtitle}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if icon == "📥" and "Download Started" in title:
|
if icon == "" and "Download Started" in title:
|
||||||
self.toast_manager.success(f"{subtitle}")
|
self.toast_manager.success(f"{subtitle}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if icon == "🔍" and "Search Complete" in title:
|
if icon == "" and "Search Complete" in title:
|
||||||
self.toast_manager.info(f"{subtitle}")
|
self.toast_manager.info(f"{subtitle}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Error activities that need immediate attention
|
# Error activities that need immediate attention
|
||||||
if icon == "❌":
|
if icon == "":
|
||||||
# Skip routine background errors
|
# Skip routine background errors
|
||||||
if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]):
|
if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]):
|
||||||
return
|
return
|
||||||
|
|
@ -3700,12 +3700,12 @@ class DashboardPage(QWidget):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Warning activities
|
# Warning activities
|
||||||
if icon == "⚠️":
|
if icon == "":
|
||||||
self.toast_manager.warning(f"{title}: {subtitle}")
|
self.toast_manager.warning(f"{title}: {subtitle}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Info activities for searches and connections
|
# Info activities for searches and connections
|
||||||
if icon == "🔍" and "Search Started" in title:
|
if icon == "" and "Search Started" in title:
|
||||||
self.toast_manager.info(f"{subtitle}")
|
self.toast_manager.info(f"{subtitle}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -3811,7 +3811,7 @@ class DashboardPage(QWidget):
|
||||||
count = self.wishlist_service.get_wishlist_count()
|
count = self.wishlist_service.get_wishlist_count()
|
||||||
|
|
||||||
if hasattr(self, 'wishlist_button'):
|
if hasattr(self, 'wishlist_button'):
|
||||||
self.wishlist_button.setText(f"🎵 Wishlist ({count})")
|
self.wishlist_button.setText(f"Wishlist ({count})")
|
||||||
|
|
||||||
# Enable/disable button based on count
|
# Enable/disable button based on count
|
||||||
if count == 0:
|
if count == 0:
|
||||||
|
|
@ -3913,7 +3913,7 @@ class DashboardPage(QWidget):
|
||||||
count = database.get_watchlist_count()
|
count = database.get_watchlist_count()
|
||||||
|
|
||||||
if hasattr(self, 'watchlist_button'):
|
if hasattr(self, 'watchlist_button'):
|
||||||
self.watchlist_button.setText(f"👁️ Watchlist ({count})")
|
self.watchlist_button.setText(f"Watchlist ({count})")
|
||||||
|
|
||||||
# Enable/disable button based on count
|
# Enable/disable button based on count
|
||||||
if count == 0:
|
if count == 0:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -474,7 +474,7 @@ class ServiceTestThread(QThread):
|
||||||
|
|
||||||
# Basic validation first
|
# Basic validation first
|
||||||
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
|
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
|
||||||
return False, "✗ Please enter both Client ID and Client Secret"
|
return False, "Please enter both Client ID and Client Secret"
|
||||||
|
|
||||||
# Save temporarily to test
|
# Save temporarily to test
|
||||||
original_client_id = config_manager.get('spotify.client_id')
|
original_client_id = config_manager.get('spotify.client_id')
|
||||||
|
|
@ -489,7 +489,7 @@ class ServiceTestThread(QThread):
|
||||||
|
|
||||||
# Check if client was created successfully (has sp object)
|
# Check if client was created successfully (has sp object)
|
||||||
if client.sp is None:
|
if client.sp is None:
|
||||||
message = "✗ Failed to create Spotify client.\nCheck your credentials."
|
message = "Failed to create Spotify client.\nCheck your credentials."
|
||||||
success = False
|
success = False
|
||||||
else:
|
else:
|
||||||
# Try a simple auth check with timeout
|
# Try a simple auth check with timeout
|
||||||
|
|
@ -498,17 +498,17 @@ class ServiceTestThread(QThread):
|
||||||
if client.is_authenticated():
|
if client.is_authenticated():
|
||||||
user_info = client.get_user_info()
|
user_info = client.get_user_info()
|
||||||
username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown'
|
username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown'
|
||||||
message = f"✓ Spotify connection successful!\nConnected as: {username}"
|
message = f"Spotify connection successful!\nConnected as: {username}"
|
||||||
success = True
|
success = True
|
||||||
else:
|
else:
|
||||||
message = "✗ Spotify authentication failed.\nPlease complete the OAuth flow in your browser."
|
message = "Spotify authentication failed.\nPlease complete the OAuth flow in your browser."
|
||||||
success = False
|
success = False
|
||||||
except Exception as auth_e:
|
except Exception as auth_e:
|
||||||
message = f"✗ Spotify authentication failed:\n{str(auth_e)}"
|
message = f"Spotify authentication failed:\n{str(auth_e)}"
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
except Exception as client_e:
|
except Exception as client_e:
|
||||||
message = f"✗ Failed to create Spotify client:\n{str(client_e)}"
|
message = f"Failed to create Spotify client:\n{str(client_e)}"
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# Restore original values
|
# Restore original values
|
||||||
|
|
@ -524,7 +524,7 @@ class ServiceTestThread(QThread):
|
||||||
config_manager.set('spotify.client_secret', original_client_secret)
|
config_manager.set('spotify.client_secret', original_client_secret)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return False, f"✗ Spotify test failed:\n{str(e)}"
|
return False, f"Spotify test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_tidal(self):
|
def _test_tidal(self):
|
||||||
"""Test Tidal connection"""
|
"""Test Tidal connection"""
|
||||||
|
|
@ -533,7 +533,7 @@ class ServiceTestThread(QThread):
|
||||||
|
|
||||||
# Basic validation first
|
# Basic validation first
|
||||||
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
|
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
|
||||||
return False, "✗ Please enter both Client ID and Client Secret"
|
return False, "Please enter both Client ID and Client Secret"
|
||||||
|
|
||||||
# Save temporarily to test
|
# Save temporarily to test
|
||||||
original_client_id = config_manager.get('tidal.client_id')
|
original_client_id = config_manager.get('tidal.client_id')
|
||||||
|
|
@ -550,14 +550,14 @@ class ServiceTestThread(QThread):
|
||||||
if client.is_authenticated() or client._ensure_valid_token():
|
if client.is_authenticated() or client._ensure_valid_token():
|
||||||
user_info = client.get_user_info()
|
user_info = client.get_user_info()
|
||||||
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
|
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
|
||||||
message = f"✓ Tidal connection successful!\nConnected as: {username}\nOAuth flow completed."
|
message = f"Tidal connection successful!\nConnected as: {username}\nOAuth flow completed."
|
||||||
success = True
|
success = True
|
||||||
else:
|
else:
|
||||||
message = "✗ Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI."
|
message = "Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI."
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
except Exception as client_e:
|
except Exception as client_e:
|
||||||
message = f"✗ Failed to create Tidal client:\n{str(client_e)}"
|
message = f"Failed to create Tidal client:\n{str(client_e)}"
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# Restore original values
|
# Restore original values
|
||||||
|
|
@ -573,7 +573,7 @@ class ServiceTestThread(QThread):
|
||||||
config_manager.set('tidal.client_secret', original_client_secret)
|
config_manager.set('tidal.client_secret', original_client_secret)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return False, f"✗ Tidal test failed:\n{str(e)}"
|
return False, f"Tidal test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_plex(self):
|
def _test_plex(self):
|
||||||
"""Test Plex connection"""
|
"""Test Plex connection"""
|
||||||
|
|
@ -591,10 +591,10 @@ class ServiceTestThread(QThread):
|
||||||
client = PlexClient()
|
client = PlexClient()
|
||||||
if client.is_connected():
|
if client.is_connected():
|
||||||
server_name = client.server.friendlyName if client.server else 'Unknown'
|
server_name = client.server.friendlyName if client.server else 'Unknown'
|
||||||
message = f"✓ Plex connection successful!\nServer: {server_name}"
|
message = f"Plex connection successful!\nServer: {server_name}"
|
||||||
success = True
|
success = True
|
||||||
else:
|
else:
|
||||||
message = "✗ Plex connection failed.\nCheck your server URL and token."
|
message = "Plex connection failed.\nCheck your server URL and token."
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# Restore original values
|
# Restore original values
|
||||||
|
|
@ -604,7 +604,7 @@ class ServiceTestThread(QThread):
|
||||||
return success, message
|
return success, message
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"✗ Plex test failed:\n{str(e)}"
|
return False, f"Plex test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_jellyfin(self):
|
def _test_jellyfin(self):
|
||||||
"""Test Jellyfin connection"""
|
"""Test Jellyfin connection"""
|
||||||
|
|
@ -634,19 +634,19 @@ class ServiceTestThread(QThread):
|
||||||
data = response.json()
|
data = response.json()
|
||||||
server_name = data.get('ServerName', 'Unknown')
|
server_name = data.get('ServerName', 'Unknown')
|
||||||
version = data.get('Version', 'Unknown')
|
version = data.get('Version', 'Unknown')
|
||||||
message = f"✓ Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}"
|
message = f"Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}"
|
||||||
return True, message
|
return True, message
|
||||||
elif response.status_code == 401:
|
elif response.status_code == 401:
|
||||||
return False, "✗ Jellyfin authentication failed.\nCheck your API key."
|
return False, "Jellyfin authentication failed.\nCheck your API key."
|
||||||
else:
|
else:
|
||||||
return False, f"✗ Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}"
|
return False, f"Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}"
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
return False, "✗ Jellyfin connection timeout.\nCheck your server URL."
|
return False, "Jellyfin connection timeout.\nCheck your server URL."
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
return False, "✗ Cannot connect to Jellyfin server.\nCheck your server URL and network."
|
return False, "Cannot connect to Jellyfin server.\nCheck your server URL and network."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"✗ Jellyfin test failed:\n{str(e)}"
|
return False, f"Jellyfin test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_navidrome(self):
|
def _test_navidrome(self):
|
||||||
"""Test Navidrome connection"""
|
"""Test Navidrome connection"""
|
||||||
|
|
@ -695,23 +695,23 @@ class ServiceTestThread(QThread):
|
||||||
|
|
||||||
if subsonic_response.get('status') == 'ok':
|
if subsonic_response.get('status') == 'ok':
|
||||||
version = subsonic_response.get('version', 'Unknown')
|
version = subsonic_response.get('version', 'Unknown')
|
||||||
message = f"✓ Navidrome connection successful!\nSubsonic API Version: {version}"
|
message = f"Navidrome connection successful!\nSubsonic API Version: {version}"
|
||||||
return True, message
|
return True, message
|
||||||
elif subsonic_response.get('status') == 'failed':
|
elif subsonic_response.get('status') == 'failed':
|
||||||
error = subsonic_response.get('error', {})
|
error = subsonic_response.get('error', {})
|
||||||
error_message = error.get('message', 'Unknown error')
|
error_message = error.get('message', 'Unknown error')
|
||||||
return False, f"✗ Navidrome authentication failed:\n{error_message}"
|
return False, f"Navidrome authentication failed:\n{error_message}"
|
||||||
else:
|
else:
|
||||||
return False, "✗ Unexpected response from Navidrome server"
|
return False, "Unexpected response from Navidrome server"
|
||||||
else:
|
else:
|
||||||
return False, f"✗ Navidrome connection failed.\nHTTP {response.status_code}: {response.text}"
|
return False, f"Navidrome connection failed.\nHTTP {response.status_code}: {response.text}"
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
return False, "✗ Navidrome connection timeout.\nCheck your server URL."
|
return False, "Navidrome connection timeout.\nCheck your server URL."
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
return False, "✗ Cannot connect to Navidrome server.\nCheck your server URL and network."
|
return False, "Cannot connect to Navidrome server.\nCheck your server URL and network."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"✗ Navidrome test failed:\n{str(e)}"
|
return False, f"Navidrome test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_soulseek(self):
|
def _test_soulseek(self):
|
||||||
"""Test Soulseek connection"""
|
"""Test Soulseek connection"""
|
||||||
|
|
@ -734,31 +734,31 @@ class ServiceTestThread(QThread):
|
||||||
response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5)
|
response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True, "✓ Soulseek connection successful!\nslskd is responding."
|
return True, "Soulseek connection successful!\nslskd is responding."
|
||||||
elif response.status_code == 401:
|
elif response.status_code == 401:
|
||||||
return False, ("✗ Invalid API key\n\n"
|
return False, ("Invalid API key\n\n"
|
||||||
"Please check your slskd API key in the configuration.")
|
"Please check your slskd API key in the configuration.")
|
||||||
else:
|
else:
|
||||||
return False, (f"✗ Soulseek connection failed\nHTTP {response.status_code}\n\n"
|
return False, (f"Soulseek connection failed\nHTTP {response.status_code}\n\n"
|
||||||
"slskd is running but returned an error.")
|
"slskd is running but returned an error.")
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
except requests.exceptions.ConnectionError as e:
|
||||||
if "refused" in str(e).lower():
|
if "refused" in str(e).lower():
|
||||||
return False, ("✗ Cannot connect to slskd\n\n"
|
return False, ("Cannot connect to slskd\n\n"
|
||||||
"slskd appears to not be running on the specified URL.\n\n"
|
"slskd appears to not be running on the specified URL.\n\n"
|
||||||
"To fix this:\n"
|
"To fix this:\n"
|
||||||
"1. Install slskd from: https://github.com/slskd/slskd\n"
|
"1. Install slskd from: https://github.com/slskd/slskd\n"
|
||||||
"2. Start slskd service\n"
|
"2. Start slskd service\n"
|
||||||
"3. Ensure it's running on the correct port (default: 5030)")
|
"3. Ensure it's running on the correct port (default: 5030)")
|
||||||
else:
|
else:
|
||||||
return False, f"✗ Network error:\n{str(e)}"
|
return False, f"Network error:\n{str(e)}"
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
return False, ("✗ Connection timed out\n\n"
|
return False, ("Connection timed out\n\n"
|
||||||
"slskd is not responding. Check if it's running and accessible.")
|
"slskd is not responding. Check if it's running and accessible.")
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
return False, f"✗ Request failed:\n{str(e)}"
|
return False, f"Request failed:\n{str(e)}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"✗ Unexpected error:\n{str(e)}"
|
return False, f"Unexpected error:\n{str(e)}"
|
||||||
|
|
||||||
class JellyfinDetectionThread(QThread):
|
class JellyfinDetectionThread(QThread):
|
||||||
progress_updated = pyqtSignal(int, str) # progress value, current url
|
progress_updated = pyqtSignal(int, str) # progress value, current url
|
||||||
|
|
@ -1004,7 +1004,7 @@ class NavidromeDetectionThread(QThread):
|
||||||
print(f"Response data: {data}")
|
print(f"Response data: {data}")
|
||||||
# Check if it's a valid Subsonic API response
|
# Check if it's a valid Subsonic API response
|
||||||
if 'subsonic-response' in data:
|
if 'subsonic-response' in data:
|
||||||
print(f"✓ Found Navidrome server at {url}")
|
print(f"Found Navidrome server at {url}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"JSON parse error: {e}")
|
print(f"JSON parse error: {e}")
|
||||||
|
|
@ -1013,7 +1013,7 @@ class NavidromeDetectionThread(QThread):
|
||||||
try:
|
try:
|
||||||
root_response = requests.get(url, timeout=timeout)
|
root_response = requests.get(url, timeout=timeout)
|
||||||
if root_response.status_code == 200 and 'navidrome' in root_response.text.lower():
|
if root_response.status_code == 200 and 'navidrome' in root_response.text.lower():
|
||||||
print(f"✓ Found Navidrome web interface at {url}")
|
print(f"Found Navidrome web interface at {url}")
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
@ -1166,7 +1166,7 @@ class SettingsPage(QWidget):
|
||||||
content_layout.addStretch()
|
content_layout.addStretch()
|
||||||
|
|
||||||
# Save button
|
# Save button
|
||||||
self.save_btn = QPushButton("💾 Save Settings")
|
self.save_btn = QPushButton("Save Settings")
|
||||||
self.save_btn.setFixedHeight(45)
|
self.save_btn.setFixedHeight(45)
|
||||||
self.save_btn.clicked.connect(self.save_settings)
|
self.save_btn.clicked.connect(self.save_settings)
|
||||||
self.save_btn.setStyleSheet("""
|
self.save_btn.setStyleSheet("""
|
||||||
|
|
@ -1333,7 +1333,7 @@ class SettingsPage(QWidget):
|
||||||
|
|
||||||
# Update button text temporarily
|
# Update button text temporarily
|
||||||
original_text = self.save_btn.text()
|
original_text = self.save_btn.text()
|
||||||
self.save_btn.setText("✓ Saved!")
|
self.save_btn.setText("Saved!")
|
||||||
self.save_btn.setStyleSheet("""
|
self.save_btn.setStyleSheet("""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background: #1aa34a;
|
background: #1aa34a;
|
||||||
|
|
@ -1397,20 +1397,20 @@ class SettingsPage(QWidget):
|
||||||
# Create client and authenticate
|
# Create client and authenticate
|
||||||
client = TidalClient()
|
client = TidalClient()
|
||||||
|
|
||||||
self.tidal_auth_btn.setText("🔐 Authenticating...")
|
self.tidal_auth_btn.setText("Authenticating...")
|
||||||
self.tidal_auth_btn.setEnabled(False)
|
self.tidal_auth_btn.setEnabled(False)
|
||||||
|
|
||||||
if client.authenticate():
|
if client.authenticate():
|
||||||
QMessageBox.information(self, "Success", "✓ Tidal authentication successful!\nYou can now use Tidal playlists.")
|
QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.")
|
||||||
self.tidal_auth_btn.setText("✅ Authenticated")
|
self.tidal_auth_btn.setText("Authenticated")
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, "Authentication Failed", "✗ Tidal authentication failed.\nPlease check your credentials and try again.")
|
QMessageBox.warning(self, "Authentication Failed", "Tidal authentication failed.\nPlease check your credentials and try again.")
|
||||||
self.tidal_auth_btn.setText("🔐 Authenticate")
|
self.tidal_auth_btn.setText("Authenticate")
|
||||||
|
|
||||||
self.tidal_auth_btn.setEnabled(True)
|
self.tidal_auth_btn.setEnabled(True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.tidal_auth_btn.setText("🔐 Authenticate")
|
self.tidal_auth_btn.setText("Authenticate")
|
||||||
self.tidal_auth_btn.setEnabled(True)
|
self.tidal_auth_btn.setEnabled(True)
|
||||||
QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}")
|
QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}")
|
||||||
|
|
||||||
|
|
@ -1826,7 +1826,7 @@ class SettingsPage(QWidget):
|
||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
||||||
success_label = QLabel(f"✓ Found Plex server running {location_type}!")
|
success_label = QLabel(f"Found Plex server running {location_type}!")
|
||||||
success_label.setStyleSheet("color: #e5a00d; font-size: 13px; font-weight: bold;")
|
success_label.setStyleSheet("color: #e5a00d; font-size: 13px; font-weight: bold;")
|
||||||
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(success_label)
|
layout.addWidget(success_label)
|
||||||
|
|
@ -1937,7 +1937,7 @@ class SettingsPage(QWidget):
|
||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
||||||
success_label = QLabel(f"✓ Found Jellyfin server running {location_type}!")
|
success_label = QLabel(f"Found Jellyfin server running {location_type}!")
|
||||||
success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;")
|
success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;")
|
||||||
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(success_label)
|
layout.addWidget(success_label)
|
||||||
|
|
@ -2048,7 +2048,7 @@ class SettingsPage(QWidget):
|
||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
|
||||||
success_label = QLabel(f"✓ Found slskd running {location_type}!")
|
success_label = QLabel(f"Found slskd running {location_type}!")
|
||||||
success_label.setStyleSheet("color: #1db954; font-size: 13px; font-weight: bold;")
|
success_label.setStyleSheet("color: #1db954; font-size: 13px; font-weight: bold;")
|
||||||
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(success_label)
|
layout.addWidget(success_label)
|
||||||
|
|
@ -2315,7 +2315,7 @@ class SettingsPage(QWidget):
|
||||||
tidal_layout.addWidget(oauth_url_label)
|
tidal_layout.addWidget(oauth_url_label)
|
||||||
|
|
||||||
# Authenticate button
|
# Authenticate button
|
||||||
self.tidal_auth_btn = QPushButton("🔐 Authenticate")
|
self.tidal_auth_btn = QPushButton("Authenticate")
|
||||||
self.tidal_auth_btn.setFixedHeight(30)
|
self.tidal_auth_btn.setFixedHeight(30)
|
||||||
self.tidal_auth_btn.setStyleSheet("""
|
self.tidal_auth_btn.setStyleSheet("""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
|
|
@ -2378,7 +2378,7 @@ class SettingsPage(QWidget):
|
||||||
server_selection_layout.addLayout(toggle_container)
|
server_selection_layout.addLayout(toggle_container)
|
||||||
|
|
||||||
# Restart warning (initially hidden)
|
# Restart warning (initially hidden)
|
||||||
self.restart_warning_frame = QLabel("⚠️ Server change requires restart - Save settings then restart SoulSync")
|
self.restart_warning_frame = QLabel("Server change requires restart - Save settings then restart SoulSync")
|
||||||
self.restart_warning_frame.setStyleSheet("""
|
self.restart_warning_frame.setStyleSheet("""
|
||||||
color: #ffc107;
|
color: #ffc107;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -2764,7 +2764,7 @@ class SettingsPage(QWidget):
|
||||||
database_layout.addWidget(workers_help)
|
database_layout.addWidget(workers_help)
|
||||||
|
|
||||||
# Metadata Enhancement Settings
|
# Metadata Enhancement Settings
|
||||||
metadata_group = SettingsGroup("🎵 Metadata Enhancement")
|
metadata_group = SettingsGroup("Metadata Enhancement")
|
||||||
metadata_layout = QVBoxLayout(metadata_group)
|
metadata_layout = QVBoxLayout(metadata_group)
|
||||||
metadata_layout.setContentsMargins(16, 20, 16, 16)
|
metadata_layout.setContentsMargins(16, 20, 16, 16)
|
||||||
metadata_layout.setSpacing(12)
|
metadata_layout.setSpacing(12)
|
||||||
|
|
@ -2831,13 +2831,13 @@ class SettingsPage(QWidget):
|
||||||
metadata_layout.addWidget(help_text)
|
metadata_layout.addWidget(help_text)
|
||||||
|
|
||||||
# Playlist Sync Settings
|
# Playlist Sync Settings
|
||||||
playlist_sync_group = SettingsGroup("🎶 Playlist Sync")
|
playlist_sync_group = SettingsGroup("Playlist Sync")
|
||||||
playlist_sync_layout = QVBoxLayout(playlist_sync_group)
|
playlist_sync_layout = QVBoxLayout(playlist_sync_group)
|
||||||
playlist_sync_layout.setContentsMargins(16, 20, 16, 16)
|
playlist_sync_layout.setContentsMargins(16, 20, 16, 16)
|
||||||
playlist_sync_layout.setSpacing(12)
|
playlist_sync_layout.setSpacing(12)
|
||||||
|
|
||||||
# Create backup checkbox
|
# Create backup checkbox
|
||||||
self.create_backup_checkbox = QCheckBox("🛡️ Create backup of existing playlists before sync")
|
self.create_backup_checkbox = QCheckBox("Create backup of existing playlists before sync")
|
||||||
self.create_backup_checkbox.setChecked(True)
|
self.create_backup_checkbox.setChecked(True)
|
||||||
self.create_backup_checkbox.setStyleSheet("""
|
self.create_backup_checkbox.setStyleSheet("""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
|
|
@ -3367,12 +3367,12 @@ class SettingsPage(QWidget):
|
||||||
# Show success toast
|
# Show success toast
|
||||||
from ui.components.toast_manager import ToastManager
|
from ui.components.toast_manager import ToastManager
|
||||||
toast_manager = ToastManager(self)
|
toast_manager = ToastManager(self)
|
||||||
toast_manager.show_toast(f"✓ Navidrome server detected: {found_url}", "success", 4000)
|
toast_manager.show_toast(f"Navidrome server detected: {found_url}", "success", 4000)
|
||||||
else:
|
else:
|
||||||
# Show error toast
|
# Show error toast
|
||||||
from ui.components.toast_manager import ToastManager
|
from ui.components.toast_manager import ToastManager
|
||||||
toast_manager = ToastManager(self)
|
toast_manager = ToastManager(self)
|
||||||
toast_manager.show_toast("❌ No Navidrome servers found on the network", "error", 4000)
|
toast_manager.show_toast("No Navidrome servers found on the network", "error", 4000)
|
||||||
|
|
||||||
def on_jellyfin_detection_completed(self, found_url):
|
def on_jellyfin_detection_completed(self, found_url):
|
||||||
"""Handle Jellyfin detection completion"""
|
"""Handle Jellyfin detection completion"""
|
||||||
|
|
|
||||||
824
ui/pages/sync.py
824
ui/pages/sync.py
File diff suppressed because it is too large
Load diff
|
|
@ -892,7 +892,7 @@ class MediaPlayer(QWidget):
|
||||||
volume_layout = QHBoxLayout()
|
volume_layout = QHBoxLayout()
|
||||||
volume_layout.setSpacing(10)
|
volume_layout.setSpacing(10)
|
||||||
|
|
||||||
volume_icon = QLabel("🔊")
|
volume_icon = QLabel("")
|
||||||
volume_icon.setStyleSheet("""
|
volume_icon.setStyleSheet("""
|
||||||
QLabel {
|
QLabel {
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
|
|
@ -933,7 +933,7 @@ class MediaPlayer(QWidget):
|
||||||
self.volume_slider.valueChanged.connect(self.on_volume_changed)
|
self.volume_slider.valueChanged.connect(self.on_volume_changed)
|
||||||
|
|
||||||
# Stop button - more visible Spotify style
|
# Stop button - more visible Spotify style
|
||||||
self.stop_btn = QPushButton("⏹")
|
self.stop_btn = QPushButton("")
|
||||||
self.stop_btn.setFixedSize(32, 32)
|
self.stop_btn.setFixedSize(32, 32)
|
||||||
self.stop_btn.setStyleSheet("""
|
self.stop_btn.setStyleSheet("""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
|
|
@ -1029,7 +1029,7 @@ class MediaPlayer(QWidget):
|
||||||
"""Update play/pause button state"""
|
"""Update play/pause button state"""
|
||||||
self.is_playing = playing
|
self.is_playing = playing
|
||||||
if playing:
|
if playing:
|
||||||
self.play_pause_btn.setText("⏸︎")
|
self.play_pause_btn.setText("")
|
||||||
# Start scrolling animation when playing
|
# Start scrolling animation when playing
|
||||||
if self.track_info.should_scroll and not self.track_info.is_scrolling:
|
if self.track_info.should_scroll and not self.track_info.is_scrolling:
|
||||||
self.track_info.start_scroll_animation()
|
self.track_info.start_scroll_animation()
|
||||||
|
|
@ -1207,11 +1207,11 @@ class ModernSidebar(QWidget):
|
||||||
|
|
||||||
# Navigation buttons
|
# Navigation buttons
|
||||||
nav_items = [
|
nav_items = [
|
||||||
("dashboard", "Dashboard", "📊"),
|
("dashboard", "Dashboard", ""),
|
||||||
("sync", "Sync", "🔄"),
|
("sync", "Sync", ""),
|
||||||
("downloads", "Search", "📥"),
|
("downloads", "Search", ""),
|
||||||
("artists", "Artists", "🎵"),
|
("artists", "Artists", ""),
|
||||||
("settings", "Settings", "⚙️")
|
("settings", "Settings", "")
|
||||||
]
|
]
|
||||||
|
|
||||||
for page_id, title, icon in nav_items:
|
for page_id, title, icon in nav_items:
|
||||||
|
|
|
||||||
4612
web_server.py
4612
web_server.py
File diff suppressed because it is too large
Load diff
|
|
@ -8,9 +8,18 @@
|
||||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Setup Wizard Overlay -->
|
||||||
|
<div id="setup-wizard-overlay" class="setup-wizard-overlay" style="display: none;">
|
||||||
|
<div class="setup-wizard-container">
|
||||||
|
<div class="setup-stepper" id="setup-wizard-stepper"></div>
|
||||||
|
<div id="setup-wizard-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Launch PIN Lock Screen -->
|
<!-- Launch PIN Lock Screen -->
|
||||||
<div id="launch-pin-overlay" class="launch-pin-overlay" style="display: none;">
|
<div id="launch-pin-overlay" class="launch-pin-overlay" style="display: none;">
|
||||||
<div class="launch-pin-container" id="launch-pin-container">
|
<div class="launch-pin-container" id="launch-pin-container">
|
||||||
|
|
@ -93,8 +102,12 @@
|
||||||
<option value="discover">Discover</option>
|
<option value="discover">Discover</option>
|
||||||
<option value="artists">Artists</option>
|
<option value="artists">Artists</option>
|
||||||
<option value="automations">Automations</option>
|
<option value="automations">Automations</option>
|
||||||
|
<option value="active-downloads">Downloads</option>
|
||||||
<option value="library">Library</option>
|
<option value="library">Library</option>
|
||||||
|
<option value="stats">Listening Stats</option>
|
||||||
|
<option value="playlist-explorer">Playlist Explorer</option>
|
||||||
<option value="import">Import</option>
|
<option value="import">Import</option>
|
||||||
|
<option value="help">Help & Docs</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="profile-settings-label">Page Access</label>
|
<label class="profile-settings-label">Page Access</label>
|
||||||
<div id="new-profile-allowed-pages" class="profile-page-checkboxes">
|
<div id="new-profile-allowed-pages" class="profile-page-checkboxes">
|
||||||
|
|
@ -104,9 +117,13 @@
|
||||||
<label><input type="checkbox" value="discover" checked> Discover</label>
|
<label><input type="checkbox" value="discover" checked> Discover</label>
|
||||||
<label><input type="checkbox" value="artists" checked> Artists</label>
|
<label><input type="checkbox" value="artists" checked> Artists</label>
|
||||||
<label><input type="checkbox" value="automations" checked> Automations</label>
|
<label><input type="checkbox" value="automations" checked> Automations</label>
|
||||||
|
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
|
||||||
<label><input type="checkbox" value="library" checked> Library</label>
|
<label><input type="checkbox" value="library" checked> Library</label>
|
||||||
|
<label><input type="checkbox" value="stats" checked> Listening Stats</label>
|
||||||
|
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
|
||||||
<label><input type="checkbox" value="import" checked> Import</label>
|
<label><input type="checkbox" value="import" checked> Import</label>
|
||||||
<label><input type="checkbox" value="help" checked disabled> Help & Docs</label>
|
<label><input type="checkbox" value="help" checked disabled> Help & Docs</label>
|
||||||
|
<label><input type="checkbox" value="issues" checked disabled> Issues</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="profile-checkbox-label">
|
<label class="profile-checkbox-label">
|
||||||
<input type="checkbox" id="new-profile-can-download" checked> Can download music
|
<input type="checkbox" id="new-profile-can-download" checked> Can download music
|
||||||
|
|
@ -199,6 +216,11 @@
|
||||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
||||||
<span class="nav-text">Automations</span>
|
<span class="nav-text">Automations</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-button" data-page="active-downloads">
|
||||||
|
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||||
|
<span class="nav-text">Downloads</span>
|
||||||
|
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
|
||||||
|
</button>
|
||||||
<button class="nav-button" data-page="library">
|
<button class="nav-button" data-page="library">
|
||||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
|
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
|
||||||
<span class="nav-text">Library</span>
|
<span class="nav-text">Library</span>
|
||||||
|
|
@ -2611,6 +2633,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Downloads Page -->
|
||||||
|
<div class="page" id="active-downloads-page">
|
||||||
|
<div class="adl-container">
|
||||||
|
<div class="adl-header">
|
||||||
|
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
|
||||||
|
<div class="adl-controls">
|
||||||
|
<div class="adl-filter-pills" id="adl-filter-pills">
|
||||||
|
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
|
||||||
|
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
|
||||||
|
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
|
||||||
|
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
|
||||||
|
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<span class="adl-count" id="adl-count"></span>
|
||||||
|
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="adl-list" id="adl-list">
|
||||||
|
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Library Page -->
|
<!-- Library Page -->
|
||||||
<div class="page" id="library-page">
|
<div class="page" id="library-page">
|
||||||
<div class="library-container">
|
<div class="library-container">
|
||||||
|
|
@ -7336,6 +7383,7 @@
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
<!-- Notification bell + floating helper toggle — always accessible above modals -->
|
<!-- Notification bell + floating helper toggle — always accessible above modals -->
|
||||||
<!-- Global Search Bar — Spotlight-style search from anywhere -->
|
<!-- Global Search Bar — Spotlight-style search from anywhere -->
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,9 @@ function handleServiceStatusUpdate(data) {
|
||||||
updateSidebarServiceStatus('media-server', data.media_server);
|
updateSidebarServiceStatus('media-server', data.media_server);
|
||||||
updateSidebarServiceStatus('soulseek', data.soulseek);
|
updateSidebarServiceStatus('soulseek', data.soulseek);
|
||||||
|
|
||||||
|
// Update downloads nav badge from status push
|
||||||
|
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
|
||||||
|
|
||||||
// Update enrichment service cards
|
// Update enrichment service cards
|
||||||
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
||||||
|
|
||||||
|
|
@ -2318,7 +2321,8 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
|
||||||
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
|
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
|
||||||
const pageLabels = {
|
const pageLabels = {
|
||||||
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
||||||
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
|
artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats',
|
||||||
|
'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs'
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
|
|
@ -2503,7 +2507,8 @@ function showSelfEditForm() {
|
||||||
|
|
||||||
const pageLabels = {
|
const pageLabels = {
|
||||||
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
|
||||||
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
|
artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats',
|
||||||
|
'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs'
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
|
|
@ -2614,6 +2619,36 @@ async function checkAdminPinRequired() {
|
||||||
document.addEventListener('DOMContentLoaded', async function () {
|
document.addEventListener('DOMContentLoaded', async function () {
|
||||||
console.log('SoulSync WebUI initializing...');
|
console.log('SoulSync WebUI initializing...');
|
||||||
|
|
||||||
|
// Check if first-run setup wizard should be shown
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const forceSetup = params.get('setup') === '1';
|
||||||
|
let showWizard = forceSetup;
|
||||||
|
|
||||||
|
if (!forceSetup) {
|
||||||
|
try {
|
||||||
|
const setupResp = await fetch('/api/setup/status');
|
||||||
|
const setupData = await setupResp.json();
|
||||||
|
if (!setupData.setup_complete) {
|
||||||
|
showWizard = true;
|
||||||
|
localStorage.removeItem('soulsync_setup_complete');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Setup status check failed, continuing normal init:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showWizard && typeof openSetupWizard === 'function') {
|
||||||
|
window._onSetupWizardComplete = function () {
|
||||||
|
_continueAppInit();
|
||||||
|
};
|
||||||
|
openSetupWizard();
|
||||||
|
return; // Defer init until wizard closes
|
||||||
|
}
|
||||||
|
|
||||||
|
_continueAppInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function _continueAppInit() {
|
||||||
// Initialize profile management UI handlers
|
// Initialize profile management UI handlers
|
||||||
initProfileManagement();
|
initProfileManagement();
|
||||||
|
|
||||||
|
|
@ -2625,7 +2660,7 @@ document.addEventListener('DOMContentLoaded', async function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
initApp();
|
initApp();
|
||||||
});
|
}
|
||||||
|
|
||||||
function initApp() {
|
function initApp() {
|
||||||
// Initialize components
|
// Initialize components
|
||||||
|
|
@ -2903,6 +2938,9 @@ async function loadPageData(pageId) {
|
||||||
restoreArtistsPageState();
|
restoreArtistsPageState();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'active-downloads':
|
||||||
|
loadActiveDownloadsPage();
|
||||||
|
break;
|
||||||
case 'library':
|
case 'library':
|
||||||
// Check if we should return to artist detail view instead of list
|
// Check if we should return to artist detail view instead of list
|
||||||
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
|
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
|
||||||
|
|
@ -34383,7 +34421,15 @@ function initializeArtistsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailBackButton) {
|
if (detailBackButton) {
|
||||||
detailBackButton.addEventListener('click', () => showArtistsResultsState());
|
detailBackButton.addEventListener('click', () => {
|
||||||
|
// If there are no search results (user navigated directly to artist),
|
||||||
|
// go straight to the main search view instead of showing an empty results page
|
||||||
|
if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) {
|
||||||
|
showArtistsSearchState();
|
||||||
|
} else {
|
||||||
|
showArtistsResultsState();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize tabs (only need to do this once)
|
// Initialize tabs (only need to do this once)
|
||||||
|
|
@ -38687,6 +38733,9 @@ async function fetchAndUpdateServiceStatus() {
|
||||||
updateSidebarServiceStatus('media-server', data.media_server);
|
updateSidebarServiceStatus('media-server', data.media_server);
|
||||||
updateSidebarServiceStatus('soulseek', data.soulseek);
|
updateSidebarServiceStatus('soulseek', data.soulseek);
|
||||||
|
|
||||||
|
// Update downloads nav badge
|
||||||
|
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
|
||||||
|
|
||||||
// Update enrichment service cards
|
// Update enrichment service cards
|
||||||
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
||||||
|
|
||||||
|
|
@ -72675,3 +72724,216 @@ function _syncDetailFilter(btn, filter) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ACTIVE DOWNLOADS PAGE — Centralized Live View
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let _adlPoller = null;
|
||||||
|
let _adlFilter = 'all';
|
||||||
|
let _adlData = [];
|
||||||
|
|
||||||
|
function loadActiveDownloadsPage() {
|
||||||
|
_adlFetch();
|
||||||
|
// Poll every 2 seconds while on this page
|
||||||
|
if (_adlPoller) clearInterval(_adlPoller);
|
||||||
|
_adlPoller = setInterval(() => {
|
||||||
|
if (currentPage === 'active-downloads') _adlFetch();
|
||||||
|
else { clearInterval(_adlPoller); _adlPoller = null; }
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adlSetFilter(filter) {
|
||||||
|
_adlFilter = filter;
|
||||||
|
document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter));
|
||||||
|
_adlRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _adlFetch() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/downloads/all?limit=300');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
_adlData = data.downloads || [];
|
||||||
|
_adlRender();
|
||||||
|
_adlUpdateBadge();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Downloads page fetch error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adlUpdateBadge() {
|
||||||
|
const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length;
|
||||||
|
_updateDlNavBadge(activeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateDlNavBadge(count) {
|
||||||
|
const badge = document.getElementById('dl-nav-badge');
|
||||||
|
if (badge) {
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adlRender() {
|
||||||
|
const list = document.getElementById('adl-list');
|
||||||
|
const empty = document.getElementById('adl-empty');
|
||||||
|
const countEl = document.getElementById('adl-count');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const activeStatuses = ['downloading', 'searching', 'post_processing'];
|
||||||
|
const queuedStatuses = ['queued'];
|
||||||
|
const completedStatuses = ['completed', 'skipped', 'already_owned'];
|
||||||
|
const failedStatuses = ['failed', 'not_found', 'cancelled'];
|
||||||
|
|
||||||
|
let filtered = _adlData;
|
||||||
|
if (_adlFilter === 'active') filtered = _adlData.filter(d => activeStatuses.includes(d.status));
|
||||||
|
else if (_adlFilter === 'queued') filtered = _adlData.filter(d => queuedStatuses.includes(d.status));
|
||||||
|
else if (_adlFilter === 'completed') filtered = _adlData.filter(d => completedStatuses.includes(d.status));
|
||||||
|
else if (_adlFilter === 'failed') filtered = _adlData.filter(d => failedStatuses.includes(d.status));
|
||||||
|
|
||||||
|
const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length;
|
||||||
|
|
||||||
|
if (countEl) {
|
||||||
|
const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
|
||||||
|
const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length;
|
||||||
|
const total = _adlData.length;
|
||||||
|
const parts = [];
|
||||||
|
if (activeN > 0) parts.push(`${activeN} active`);
|
||||||
|
if (queuedN > 0) parts.push(`${queuedN} queued`);
|
||||||
|
parts.push(`${total} total`);
|
||||||
|
countEl.textContent = parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide clear button
|
||||||
|
const clearBtn = document.getElementById('adl-clear-btn');
|
||||||
|
if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none';
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
if (empty) empty.style.display = '';
|
||||||
|
// Clear any existing rows but keep the empty message
|
||||||
|
list.querySelectorAll('.adl-row').forEach(r => r.remove());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty) empty.style.display = 'none';
|
||||||
|
|
||||||
|
// Group by status category for section headers
|
||||||
|
const groups = { active: [], queued: [], completed: [], failed: [] };
|
||||||
|
for (const dl of filtered) {
|
||||||
|
const cls = _adlStatusClass(dl.status);
|
||||||
|
if (cls === 'active') groups.active.push(dl);
|
||||||
|
else if (cls === 'queued') groups.queued.push(dl);
|
||||||
|
else if (cls === 'completed') groups.completed.push(dl);
|
||||||
|
else groups.failed.push(dl);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
const sections = [
|
||||||
|
{ key: 'active', label: 'Active', items: groups.active },
|
||||||
|
{ key: 'queued', label: 'Queued', items: groups.queued },
|
||||||
|
{ key: 'completed', label: 'Completed', items: groups.completed },
|
||||||
|
{ key: 'failed', label: 'Failed', items: groups.failed },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.items.length === 0) continue;
|
||||||
|
// Only show section headers in "all" filter mode
|
||||||
|
if (_adlFilter === 'all') {
|
||||||
|
html += `<div class="adl-section-header">${section.label} (${section.items.length})</div>`;
|
||||||
|
}
|
||||||
|
for (const dl of section.items) {
|
||||||
|
const statusClass = _adlStatusClass(dl.status);
|
||||||
|
const statusLabel = _adlStatusLabel(dl.status);
|
||||||
|
const title = _adlEsc(dl.title || 'Unknown Track');
|
||||||
|
const artist = _adlEsc(dl.artist || '');
|
||||||
|
const album = _adlEsc(dl.album || '');
|
||||||
|
const batchName = _adlEsc(dl.batch_name || '');
|
||||||
|
const error = dl.error ? _adlEsc(dl.error) : '';
|
||||||
|
|
||||||
|
const meta = [artist, album].filter(Boolean).join(' \u00B7 ');
|
||||||
|
const artHtml = dl.artwork
|
||||||
|
? `<img class="adl-row-art" src="${_adlEsc(dl.artwork)}" alt="" onerror="this.style.display='none'">`
|
||||||
|
: '<div class="adl-row-art adl-row-art-empty"></div>';
|
||||||
|
|
||||||
|
// Track position: "3 of 19"
|
||||||
|
const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : '';
|
||||||
|
|
||||||
|
html += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}">
|
||||||
|
${artHtml}
|
||||||
|
<div class="adl-row-info">
|
||||||
|
<div class="adl-row-title">${title}</div>
|
||||||
|
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
|
||||||
|
${batchName ? `<div class="adl-row-batch">${batchName}${posText ? ' · Track ' + posText : ''}</div>` : ''}
|
||||||
|
${error ? `<div class="adl-row-error">${error}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="adl-row-status ${statusClass}">
|
||||||
|
<span class="adl-status-dot ${statusClass}"></span>
|
||||||
|
${statusLabel}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve empty element, inject rows
|
||||||
|
const emptyEl = document.getElementById('adl-empty');
|
||||||
|
const emptyHtml = emptyEl ? emptyEl.outerHTML : '';
|
||||||
|
list.innerHTML = emptyHtml + html;
|
||||||
|
const newEmpty = document.getElementById('adl-empty');
|
||||||
|
if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adlStatusClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'downloading': case 'searching': case 'post_processing': return 'active';
|
||||||
|
case 'queued': case 'pending': return 'queued';
|
||||||
|
case 'completed': case 'skipped': case 'already_owned': return 'completed';
|
||||||
|
case 'failed': case 'not_found': return 'failed';
|
||||||
|
case 'cancelled': return 'cancelled';
|
||||||
|
default: return 'queued';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adlStatusLabel(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'downloading': return '<span class="adl-spinner"></span>Downloading';
|
||||||
|
case 'searching': return '<span class="adl-spinner"></span>Searching';
|
||||||
|
case 'post_processing': return '<span class="adl-spinner"></span>Processing';
|
||||||
|
case 'queued': case 'pending': return 'Queued';
|
||||||
|
case 'completed': return 'Completed';
|
||||||
|
case 'skipped': return 'Skipped';
|
||||||
|
case 'already_owned': return 'Owned';
|
||||||
|
case 'failed': return 'Failed';
|
||||||
|
case 'not_found': return 'Not Found';
|
||||||
|
case 'cancelled': return 'Cancelled';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adlEsc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adlClearCompleted() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success');
|
||||||
|
_adlFetch();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error clearing completed downloads:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.adlSetFilter = adlSetFilter;
|
||||||
|
window.adlClearCompleted = adlClearCompleted;
|
||||||
|
|
|
||||||
794
webui/static/setup-wizard.css
Normal file
794
webui/static/setup-wizard.css
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
/* ============================================
|
||||||
|
SETUP WIZARD — First-Run Full-Screen Overlay
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.setup-wizard-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100000;
|
||||||
|
background: linear-gradient(135deg, #0a0a1a 0%, #111827 50%, #0a0a1a 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: setupFadeIn 0.5s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes setupFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-wizard-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress Stepper ---- */
|
||||||
|
.setup-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-dot.active {
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
border-color: rgb(var(--accent-rgb));
|
||||||
|
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-dot.completed {
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
border-color: rgb(var(--accent-rgb));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-line {
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-line.completed {
|
||||||
|
background: rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Card Container ---- */
|
||||||
|
.setup-card {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: setupCardIn 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes setupCardIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card .setup-subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Welcome Step ---- */
|
||||||
|
.setup-welcome-logo {
|
||||||
|
width: auto;
|
||||||
|
height: 80px;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-welcome-tagline {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-feature-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-feature-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-feature-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Selection Cards (Metadata / Download Source) ---- */
|
||||||
|
.setup-option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-card {
|
||||||
|
position: relative;
|
||||||
|
padding: 18px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-card.selected {
|
||||||
|
border-color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.08);
|
||||||
|
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-card.selected::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
|
||||||
|
background-size: 14px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline config that appears when an option is selected */
|
||||||
|
.setup-inline-config {
|
||||||
|
display: none;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: setupCardIn 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-inline-config.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Form Inputs ---- */
|
||||||
|
.setup-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-input-group label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-input:focus {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-test-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-test-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-test-btn.success {
|
||||||
|
border-color: #22c55e;
|
||||||
|
color: #22c55e;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-test-btn.failed {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Media Server Cards ---- */
|
||||||
|
.setup-server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-server-card {
|
||||||
|
padding: 14px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-server-card:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-server-card.selected {
|
||||||
|
border-color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-server-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Artist Search (Step 5) ---- */
|
||||||
|
.setup-search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px 12px 42px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-search-input:focus {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-search-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(var(--accent-rgb), 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-row.added {
|
||||||
|
border-color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-img {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-genre {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Added artists chips */
|
||||||
|
.setup-added-artists {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-added-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-added-chip .remove {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-added-chip .remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Track Search (Step 6) ---- */
|
||||||
|
.setup-track-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(var(--accent-rgb), 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-row.downloading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-row.downloaded {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-art {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-artist {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-status {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-track-row.downloaded .setup-track-status {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Done Step ---- */
|
||||||
|
.setup-done-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: setupPulse 2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes setupPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.2); }
|
||||||
|
50% { box-shadow: 0 0 0 16px rgba(var(--accent-rgb), 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-label {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-value {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.setup-btn-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn-primary {
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn-primary:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn-big {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-skip-link {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-skip-link:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Path Lock Row ---- */
|
||||||
|
.setup-path-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-path-row .setup-path-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-path-row .setup-path-input[readonly] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-lock-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-lock-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-lock-btn.locked {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
color: rgba(var(--accent-rgb), 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Artist check column ---- */
|
||||||
|
.setup-artist-check {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-artist-row.added .setup-artist-check {
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Info Tip Boxes ---- */
|
||||||
|
.setup-info-box {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.06);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-info-box strong {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-info-box ul {
|
||||||
|
margin: 6px 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-info-box ul li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-info-box + .setup-info-box {
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Done Page Tips ---- */
|
||||||
|
.setup-tips-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-tip-card {
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-tip-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-tip-text {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Loading Spinner ---- */
|
||||||
|
.setup-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: rgb(var(--accent-rgb));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: setupSpin 0.6s linear infinite;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes setupSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.setup-card {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-option-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-server-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-line {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-tips-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
1064
webui/static/setup-wizard.js
Normal file
1064
webui/static/setup-wizard.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34972,38 +34972,54 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
|
|
||||||
/* Tooltip */
|
/* Tooltip */
|
||||||
.discogs-tooltip {
|
.discogs-tooltip {
|
||||||
display: none;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 12px);
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
top: calc(100% + 12px);
|
||||||
z-index: 100;
|
transform: translateX(-50%) translateY(-5px);
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discogs-button-container:hover .discogs-tooltip { display: block; }
|
.discogs-button:hover+.discogs-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.discogs-tooltip-content {
|
.discogs-tooltip-content {
|
||||||
background: rgba(14, 14, 20, 0.97);
|
min-width: 260px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
background: linear-gradient(135deg, rgba(30, 30, 30, 0.98) 0%, rgba(20, 20, 20, 0.99) 100%);
|
||||||
border-radius: 12px;
|
backdrop-filter: blur(40px) saturate(1.6);
|
||||||
padding: 12px 16px;
|
-webkit-backdrop-filter: blur(40px) saturate(1.6);
|
||||||
min-width: 200px;
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
backdrop-filter: blur(20px);
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
padding: 16px 18px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.discogs-tooltip-header {
|
.discogs-tooltip-header {
|
||||||
|
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.95);
|
||||||
margin-bottom: 8px;
|
letter-spacing: -0.2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.discogs-tooltip-body {
|
.discogs-tooltip-body {
|
||||||
font-size: 11px;
|
display: flex;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
flex-direction: column;
|
||||||
line-height: 1.6;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#discogs-tooltip-status {
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deezer-button-container {
|
.deezer-button-container {
|
||||||
|
|
@ -55162,3 +55178,339 @@ body.reduce-effects *::after {
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ACTIVE DOWNLOADS PAGE — Premium Glassmorphic
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.adl-container {
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-title svg {
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-filter-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-pill {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-pill:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-pill.active {
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-clear-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-clear-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section Headers ---- */
|
||||||
|
|
||||||
|
.adl-section-header {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 12px 14px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Download Row ---- */
|
||||||
|
|
||||||
|
.adl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.035);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active row accent glow */
|
||||||
|
.adl-row.adl-row-active {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.18);
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.04) 0%, rgba(var(--accent-rgb), 0.01) 100%);
|
||||||
|
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completed row subtle green */
|
||||||
|
.adl-row.adl-row-completed {
|
||||||
|
border-color: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Failed row subtle red */
|
||||||
|
.adl-row.adl-row-failed {
|
||||||
|
border-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-art {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-art-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-batch {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(var(--accent-rgb), 0.55);
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-error {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(239, 68, 68, 0.65);
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status Badges ---- */
|
||||||
|
|
||||||
|
.adl-row-status {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 95px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status.active {
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status.queued {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status.completed {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status.failed {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status.cancelled {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot indicator */
|
||||||
|
.adl-status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-status-dot.active {
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
|
||||||
|
animation: adlPulse 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-status-dot.queued { background: rgba(255, 255, 255, 0.2); }
|
||||||
|
.adl-status-dot.completed { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.3); }
|
||||||
|
.adl-status-dot.failed { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); }
|
||||||
|
.adl-status-dot.cancelled { background: rgba(255, 255, 255, 0.15); }
|
||||||
|
|
||||||
|
@keyframes adlPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Spinner ---- */
|
||||||
|
|
||||||
|
.adl-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1.5px solid rgba(var(--accent-rgb), 0.2);
|
||||||
|
border-top-color: rgb(var(--accent-rgb));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: adlSpin 0.6s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes adlSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Nav Badge ---- */
|
||||||
|
|
||||||
|
.dl-nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.adl-container {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-filter-pills {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-art {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-row-status {
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue